From 4a6e80053074029a285df9b2661510ca34969867 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Tue, 18 Aug 2026 17:24:45 -0700 Subject: [PATCH 01/16] Add initial draft of Rust API design document --- RUST_API.md | 729 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 RUST_API.md diff --git a/RUST_API.md b/RUST_API.md new file mode 100644 index 000000000000000..e5c9acbdfa0d4b9 --- /dev/null +++ b/RUST_API.md @@ -0,0 +1,729 @@ +# Rust for CPython API Design Doc for 3.16 + +### Overview + +This document designs a safe Rust API for in-tree CPython modules, initially +covering the features needed by `zlib` and providing a base for later Rust +work. + +### Context + +Rust can call CPython's C API efficiently, but raw FFI does not encode Rust's +lifetime, aliasing, or thread-safety rules. A safe wrapper is needed to retain +those guarantees when implementing CPython modules in Rust. + +### Goals + +In priority order, the API should: + +1. be sound on GIL and free-threaded builds; +2. support every PEP 11 platform; +3. approach equivalent C performance where safety permits; +4. feel familiar to PyO3 and C API users; and +5. use modern APIs such as `PyModExport`. + +Every use of `unsafe` requires a `// SAFETY:` comment stating its proof +obligation. No panic may escape a non-unwinding C ABI. + +### Non-goals + +Full C API parity and subinterpreter support are out of scope. Wrappers are +added as modules need them; generated modules reject subinterpreter imports. + +### Specific guidelines + +- Encode CPython contracts in the type system where practical. +- Use macros sparingly to control compile time. +- Preserve Rust's initialization, aliasing, and concurrency invariants. +- Support both GIL and free-threaded builds. + +### API design + +1. Bindings and the FFI boundary +2. Managing the thread context +3. Python object handles and garbage collection +4. Exceptions and error handling +5. Concrete object abstractions +6. Rust/Python conversions +7. Module definition +8. Function definition +9. Class and method definition +10. Argument parsing +11. Testing +12. Open decisions and future work + +#### Bindings and the FFI boundary + +`cpython-sys` generates raw bindings at build time from `Python.h`, internal +headers, and the active `pyconfig.h`. This captures platform, target, debug, +and free-threading-dependent layout information. Bindgen allowlists public and private +`Py*` and `_Py*` names. Some C constructs are not replicatable via C bindings such as macros. +These constructs are replicated in Rust. + +Python objects allow mutation through shared pointers, so their binding uses +`UnsafeCell`: + +```rust +#[repr(transparent)] +pub struct PyObject(UnsafeCell<_object>); +``` + +All C calls pass through a private `c_api` module. A safe operation that may +touch Python requires `ThreadContext`, an abstraction over the thread-state; an operation without one needs an +audited proof that its entire call path is thread-state-free. Existing C APIs +may obtain the current state internally. + +#### Managing the thread context + +`ThreadContext<'py>` proves that the current OS thread has an attached CPython +thread state for `'py`: + +```rust +pub struct ThreadContext<'py> { + ptr: NonNull, + detach: Option>, + #[allow(clippy::type_complexity)] + _marker: PhantomData<(fn(&'py ()) -> &'py (), *mut ())>, +} + +struct DetachPermit<'py> { + _marker: PhantomData<&'py mut RustEntryGuard>, +} +``` + +The invariant marker makes the context `!Send + !Sync`. Neither type is +`Copy` or `Clone`, and only generated entry glue constructs them. + +Each FFI entry increments a depth stored on the actual `PyThreadState` and +holds an unwind-safe `RustEntryGuard` for the full callback. Process-wide +storage is required because separate `cdylib`s may contain separate Rust TLS. +Only depth one receives a `DetachPermit`; reentrant callbacks remain attached +but cannot detach. `RustEntryGuard::drop` restores the exact previous depth on +normal return and unwind. + +Entry uses a higher-ranked closure so callers cannot choose `'py = 'static` or +return an attached value: + +```rust +impl RustEntryGuard { + fn context<'scope>(&'scope mut self) -> ThreadContext<'scope> { + let detach = self.is_outermost().then(|| DetachPermit { + _marker: PhantomData, + }); + ThreadContext { + ptr: self.ptr(), + detach, + _marker: PhantomData, + } + } +} + +// SAFETY: called only from generated C callbacks with an attached state. +unsafe fn with_current( + f: impl for<'py> FnOnce(&mut ThreadContext<'py>) -> R, +) -> R { + // SAFETY: guaranteed by the caller. + let ptr = unsafe { current_attached_tstate() }; + let mut entry = RustEntryGuard::enter(ptr); + let mut ctx = entry.context(); + f(&mut ctx) +} +``` + +The guard borrow keeps it alive through the `'py` lifetime; `for<'py>` makes the lifetime +fresh; and `R` cannot depend on it. Generated wrappers convert attached results to +raw C values before returning. Macros reject author functions that request a +specific lifetime such as `'static`. + +Callback arguments have a separate owner lifetime, described under argument +parsing; constructing that owner is unsafe and confined to generated glue code. + +Detachment requires `&mut ThreadContext` and the outermost-entry permit: + +```rust +impl<'py> ThreadContext<'py> { + pub fn detach(&mut self, f: F) -> PyResult + where + F: FnOnce() -> R + Send, + R: Send, + { + self.detach_impl(f) + } +} +``` + +The exclusive borrow prevents use of that context while detached. The `Send` +bounds exclude attached handles from the closure and its result. Missing +permits produce `RuntimeError` without detaching. Internally, a guard uses +`_PyThreadState_Detach` and `_PyThreadState_Attach` to restore the exact state +even if `f` panics: + +```rust +let output = ctx.detach(move || compress_without_python(input))?; +``` + +#### Python object handles and garbage collection + +Three private generic handle representations distinguish borrowing, ownership, +and attachment: + +| Handle | Owns a reference | Lifetime | Auto traits | Drop | +| --- | --- | --- | --- | --- | +| `Borrowed<'a, 'py, T>` | No | owner `'a` and attachment `'py` | `Copy`, `!Send`, `!Sync` | none | +| `Bound<'py, T>` | Yes | attachment `'py` | `!Send`, `!Sync` | decref while attached | +| `Py` | Yes | unbound, for stored state | `Send`, `Sync` | checked policy below | + +The two `Borrowed` lifetimes are independent: an owner keeps the object alive +for `'a`, while `ThreadContext` permits access for `'py`. `Py` may cross +threads, but touching its object still requires an active context. + +Moving among the forms is explicit: + +```rust +let stored: Py = exception_type.unbind(); +let current: Borrowed<'_, 'py, PyType> = stored.bind(ctx); +let retained: Bound<'py, PyType> = current.to_owned(ctx); +``` + +Neither owning handle implements `Clone`; `clone_ref(ctx)` and +`to_owned(ctx)` make each incref explicit. Raw borrowed and owned-pointer +constructors are private. Their glue proves non-nullness, interpreter identity, +liveness, and the C ownership contract before constructing a handle. + +Owning handles implement consuming explicit destruction: + +```rust +object.drop_with(ctx); +``` + +`drop_with` uses `ManuallyDrop` to avoid a second automatic drop. Ordinary +`Drop` cannot receive a context, so it checks attachment with +`PyThreadState_GetUnchecked`: attached drops decref; detached drops leak and +record a Python-free, non-reentrant, non-allocating, non-panicking diagnostic. +Leaking is a programming error but remains memory-safe. `Bound` normally +cannot reach this case because it is attachment-bound and `!Send + !Sync`. + +`PyAny` is the erased object marker. More specific markers, such as `PyBytes` +and `PyType`, have the same transparent layout, so upcasts to `PyAny` do not +touch the refcount. There is intentionally no `&mut PyAny`: Python objects +have interior mutability and can be changed through aliases, especially in a +free-threaded build. + +Cyclic GC tracking is handled for modules and classes and described in their respective sections. + + +#### Exceptions and error handling + +Errors use a non-owning marker for the current thread's pending exception: + +```rust +#[must_use] +pub struct PyErrRaised { + _priv: PhantomData<*mut ()>, +} + +pub type PyResult = Result; +``` + +`PyErrRaised` is `!Send` and `#[must_use]`. Built-in and custom exception types +provide `raise(ctx, message)`, so Rust code can use `?` without copying the +Python exception: + +```rust +fn checked_size(ctx: &ThreadContext<'_>, size: usize) -> PyResult { + i32::try_from(size) + .map_err(|_| PyOverflowError::raise(ctx, "size is too large")) +} +``` + +C wrappers apply each function's error contract. Ambiguous numeric sentinels +also require checking whether an exception is pending. Once failure is known, +`assume_set` converts the ambient exception to the marker: + +```rust +if ptr.is_null() { + // SAFETY: this branch is reached only for a C API whose documented NULL + // result guarantees that it set an exception on `ctx`'s thread state. + Err(unsafe { PyErrRaised::assume_set(ctx) }) +} else { + // Turn the owned pointer into a Bound value. +} +``` + +`assume_set` is unsafe because a false marker would let a callback return an +error sentinel without an exception; it therefore debug-checks the state. At +the FFI boundary, `Err` requires a pending exception; otherwise the trampoline +sets `SystemError`. `Ok` requires no pending exception; on mismatch the +trampoline releases the result and returns its error sentinel. These checks +also reject stale saved markers. + + +#### Concrete object abstractions + +Concrete types such as `PyBytes` and `PyModule` are transparent markers. APIs +operate on the marker or `Bound<'py, Marker>`, so type-specific methods require +a checked or statically known marker. The initial set grows with module needs. + +`PyBytes::new_with` zeroes an unpublished payload before lending it as +`&mut [u8]`. `new_with_uninit` avoids zeroing but is unsafe: + +```rust +let bytes = PyBytes::new_with(ctx, output_len, |output| { + encode_into(input, output); + Ok(()) +})?; + +// SAFETY: the callback writes every output element before returning `Ok(())`. +let bytes_uninit = unsafe { + PyBytes::new_with_uninit(ctx, output_len, |output| { + initialize_every_byte(output); + Ok(()) + }) +}?; +``` + +The unsafe form lends `&mut [MaybeUninit]`, which must be fully initialized. +Error and panic paths discard the unpublished object, so they may leave it partially initialized. +Therefore the closure must not panic. + +The other initial abstractions are deliberately small. `Bound` can +add converted values and type objects, while `PyType::new_exception` creates +a heap exception type. `PyType` defaults to an untyped class marker for these +general operations; the class-definition section specializes it as +`PyType` for types whose instances contain a Rust payload `C`. + + +#### Rust/Python conversions + +Conversions use one trait per direction: + +```rust +pub trait FromPyObject<'a, 'py>: Sized { + fn extract( + ctx: &ThreadContext<'py>, + obj: Borrowed<'a, 'py, PyAny>, + ) -> PyResult; +} + +pub trait IntoPyObject<'py> { + fn into_pyobject( + self, + ctx: &ThreadContext<'py>, + ) -> PyResult>; +} +``` + +Both are fallible because they may allocate or invoke Python protocols. Their +shared `'py` lifetime prevents attached results from escaping. `FromPyObject` preserves +the owner's `'a` lifetime, allowing zero-refcount borrowed arguments; retaining one +requires `to_owned(ctx)`. + +Generic implementations compose. `Option` maps `None`, borrowed handles can +be promoted with an incref, and an existing `Bound` can be returned without +refcount traffic: + +```rust +let value: Option = FromPyObject::extract(ctx, argument)?; +let object: Bound<'py, PyAny> = value.into_pyobject(ctx)?; +``` + +Generated callbacks accept a convertible value or `PyResult`. Returning `T` means the Rust function +body cannot itself report a Python exception, although converting `T` into a +Python object may still fail. Returning `PyResult` lets the function use `?` +to propagate `PyErrRaised`; only an `Ok(T)` value is converted. + + +#### Module definition + +Modules use PEP 793's `PyModExport` and `PySlot` representation rather than a +legacy `PyModuleDef` initializer. A declarative `export_module!` macro builds +the static method table, ABI declaration, slot array, lifecycle callbacks, +and correctly named export symbol: + +```rust +export_module! { + name: _base64, + doc: c"Base64 encoding implemented in Rust", + state: Base64State, + methods: [standard_b64encode], + exec: base64_exec, +} +``` + +The declarative macro expands to inspectable static Rust items. +`#[pyfunction]` emits `ModuleMethodDef`; `export_module!` accepts only +methods carrying its declared `S`, so state mismatches are Rust type errors. + +Generated entry points represent a module whose definition is known with a +typed borrowed wrapper: + +```rust +pub struct ModuleRef<'a, 'py, S> { + module: Borrowed<'a, 'py, PyModule>, + _state: PhantomData S>, +} +``` + +Only generated `ModuleDef` glue constructs `ModuleRef` and proves the +state type. + +Modules always have a typed, per-module state, even if it is empty: + +```rust +pub trait ModuleState: Send + Sync + 'static + Sized { + type Gc: ModuleStateGc; + + fn new<'a, 'py>( + ctx: &ThreadContext<'py>, + module: ModuleRef<'a, 'py, Self>, + ) -> PyResult; +} + +// SAFETY: implementations must report every strong Python edge, clear each +// edge at most once, and remain valid after partial clearing. +pub unsafe trait ModuleStateGc: 'static { + const ENABLED: bool; + + fn traverse(value: &S, visit: &Visit<'_>) -> TraverseResult; + fn clear(value: &mut S, ctx: &ThreadContext<'_>); +} + +#[derive(ModuleStateGc)] +struct Base64State { + error: Option>, +} + +impl ModuleState for Base64State { + type Gc = Base64StateGc; + + fn new<'a, 'py>( + _ctx: &ThreadContext<'py>, + _module: ModuleRef<'a, 'py, Self>, + ) -> PyResult { + Ok(Self { error: None }) + } +} +``` + +`Send + Sync` supports concurrent access in free-threaded builds; stored +Python objects use `Py`. + +`#[derive(ModuleStateGc)]` emits the associated policy. A sealed field trait +recurses through `Py`, `Option`, and supported containers; Rust-only +state emits `ENABLED = false` and installs no GC callbacks. Manual policy +implementations are unsafe: they must report every strong edge, clear each at +most once, tolerate partial clear, and never block during traversal. + +CPython allocates `StateStorage`, an atomic phase plus `MaybeUninit`, +before Rust constructs `S`. The macro rejects types whose alignment exceeds +the allocator's documented guarantee and checks all size arithmetic. +Repeated, reentrant, and concurrent exec calls require these explicit phases: + +| Operation | Allowed source phase | Published phase and access | +| --- | --- | --- | +| Claim exec | `Uninitialized` | `Initializing`; any other source raises `SystemError`. | +| Finish `S::new` | `Initializing` | `FailedWithoutValue`, or write `S` and publish `Executing`. | +| Finish exec | `Executing` | `Ready` or `FailedWithValue`; `S` remains live. | +| Read state | `Ready` | Borrow `&S` for the `ModuleRef` lifetime. | +| Traverse | `Executing`, `Ready`, `FailedWithValue`, `Clearing`, `Cleared`, `ClearFailed` | Borrow `&S`; never traverse `Dropping` or `Dropped`. | +| Clear | `Ready` or `FailedWithValue` | Enter `Clearing`, lend `&mut S`, then publish `Cleared` or `ClearFailed`; later clears are no-ops. | +| Free with value | `Executing`, `Ready`, `FailedWithValue`, `Clearing`, `Cleared`, or `ClearFailed` | Enter `Dropping`, drop once, then publish `Dropped`. | +| Free without value | `Uninitialized` or `FailedWithoutValue` | Publish `Dropped` without calling `drop_in_place`. | + +The exec hook has the typed signature: + +```rust +type ModuleExec = for<'a, 'py> fn( + &ThreadContext<'py>, + ModuleRef<'a, 'py, S>, + &S, +) -> PyResult<()>; +``` + +Non-panicking guards apply failure phases during unwinding. Stores that +publish an initialized value or completed transition use release ordering; +dependent reads use acquire ordering. + +CPython calls clear only after unreachability, and callback borrows cannot +escape, which justifies lending `&mut S`. Derived clear takes each `Py` +before `drop_with(ctx)`, making it idempotent. + +Free never retries a panicking destructor. Non-panicking guards still publish +`Dropped` and release the allocation. + +Generated modules declare `Py_mod_gil = Py_MOD_GIL_NOT_USED`, the internal +exact-version ABI, the GIL/free-threaded build mode, and +`Py_mod_multiple_interpreters = Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED`. +Stable-ABI and subinterpreter support are out of scope. + + +#### Function definition + +Module-scoped, Python-callable Rust functions remain ordinary Rust functions with two +explicit leading parameters: the thread context and the module state. +Python-visible arguments follow them. This interim example accepts exact bytes +pending a buffer API design: + +```rust +#[pyfunction(signature = (data, /))] +fn standard_b64encode<'a, 'py>( + ctx: &ThreadContext<'py>, + _state: &Base64State, + data: Borrowed<'a, 'py, PyBytes>, +) -> PyResult> { + // ... +} +``` + +The macro identifies context and state by position, then emits compiler-checked +uses of their declared types. The state determines `ModuleMethodDef`. +Functions that detach take `&mut ThreadContext`; others take a shared borrow. + +`#[pyfunction]` preserves the Rust function body and emits its typed definition, +argument specification, and `METH_FASTCALL | METH_KEYWORDS` trampoline. The +trampoline enters panic containment and `with_current`, validates scoped C +inputs, recovers ready state, binds arguments, invokes Rust, and converts the +result to C before `'py` ends. + +The C input is untyped, so private glue validates it against the selected +`ModuleDef` before constructing a callback-scoped `ModuleRef`. Methods +use the same design with `METH_METHOD`. + +Panic containment covers entry bookkeeping, parsing, user code, conversion, +and cleanup. A pointer-returning boundary is conceptually: + +```rust +// SAFETY: callers must obey `PyCFunctionFastWithKeywords`, provide live +// callback arguments, and invoke this function with an attached thread state. +unsafe extern "C" fn trampoline( + /* existing PyCFunctionFastWithKeywords arguments */ +) -> *mut ffi::PyObject { + let call = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: the callback has an attached state and live arguments; the + // closure consumes every attached value before `'py` ends. + unsafe { + with_current(|ctx| { + let result = call_rust_function(ctx); + translate_result_to_raw(ctx, result) + }) + } + })); + + match call { + Ok(ptr) => ptr, + Err(payload) => { + dispose_panic_payload(payload); + // SAFETY: unwinding restored attachment; this fixed-message helper + // does not allocate, invoke user code, or unwind. + unsafe { set_fixed_callback_panic_exception() }; + std::ptr::null_mut() + } + } +} +``` + +Because `panic_any` payload destruction may panic, `dispose_panic_payload` +drops it under a second catch; a second panic is leaked and aborts. Reporting +uses a fixed unraisable exception message and aborts if it cannot remain non-panicking. + +Not every callback ABI can report a normal Python exception, so generated glue +uses a callback-specific policy: + +| Callback kind | Panic policy | +| --- | --- | +| Function, method, getter, setter, and constructor | Set a fixed `SystemError` and return the ABI's `NULL` or `-1` error sentinel. | +| Module create | Set a fixed `SystemError` and return `NULL`. | +| Module exec | Commit the appropriate failed state phase, set a fixed `SystemError`, and return `-1`. | +| Module or instance clear | Finish the lifecycle transition, set `SystemError`, and return failure; CPython reports it as unraisable where required. | +| Traverse | Never leave a Python exception pending. A panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | +| `tp_dealloc` and module free | Run mandatory non-panicking cleanup guards first, then report the panic as unraisable when an attached state is available. Abort if cleanup itself cannot be completed safely. | + +Before `drop_in_place`, deallocation commits the destroyed phase and +installs guards for the Python allocation and heap-type reference. A panic +therefore cannot cause a second Rust drop or skip mandatory cleanup. Module +state uses the same pattern. + + +#### Class definition and method definition + +A Rust payload type `C` identifies one Python class, its module state, and its +GC policy: + +```rust +pub trait PyClass: Send + Sync + 'static + Sized { + type State: ModuleState; + type Gc: PyClassGc; + + fn definition() -> &'static ClassDef; +} + +// SAFETY: implementations must report every strong Python edge, clear each +// edge at most once, and synchronize access according to the class lifecycle. +pub unsafe trait PyClassGc: 'static { + const ENABLED: bool; + + fn traverse(value: &C, visit: &Visit<'_>) -> TraverseResult; + fn clear(value: &mut C, ctx: &ThreadContext<'_>); +} + +#[derive(PyClassGc)] +struct Encoder { + level: u32, +} + +impl PyClass for Encoder { + type State = ModuleStateType; + type Gc = EncoderGc; + + fn definition() -> &'static ClassDef { + &ENCODER_CLASS + } +} + +static ENCODER_CLASS: ClassDef = + ClassDef::new(c"example.Encoder") + .methods(&ENCODER_METHODS) + .getsets(&ENCODER_GETSETS); +``` + +This prevents safe code from combining an unrelated payload, module state, or +definition. `#[derive(PyClassGc)]` uses the same sealed field rules as module +state and disables GC slots when no field can own Python references. Manual +implementations are unsafe because they must report every edge, clear it once, +and obey the class's synchronization rules. + +`PyType` and `PyInstance` are transparent +markers over this identity. The default represents an ordinary untyped Python +type object. + +`ENCODER_CLASS` creates `Bound>`; module state stores +`Py>`. Upcasts to untyped `PyType` or `PyAny` are free. +Downcasts must validate the class token. Generated methods, getsets, and +constructors all carry `C`; method state is fixed to `C::State`. + +`ClassDef` computes the payload layout with checked arithmetic and installs +lifecycle slots. Types whose alignment exceeds the object allocator's +documented guarantee are rejected before allocation. The allocation records +initialization, clear, and drop phases so callbacks never read uninitialized +or destroyed `C`. + +`ClassDef::create(ModuleRef)` combines static slots with the +runtime module pointer and returns `Bound>`. It verifies the unique +`C::definition()`; a second definition requires a Rust newtype. The definition +address is also stored as `Py_tp_token`. `new_instance(payload)` validates that +token before allocation or payload access, then returns +`Bound>`. + +Rust structs in classes must be `Send + Sync + 'static`. Python methods receive only `&self`, +never `&mut self`, because aliases can call the same object concurrently in a +free-threaded interpreter. Mutable payload state therefore requires interior +mutability. The rules for a blocking interpreter-aware lock remain an open +decision listed in the appendix; an ordinary `Mutex` must not be presented as +the general solution until those rules are settled. + + +Python-owning fields enable generated GC slots: + +```rust +#[derive(PyClassGc)] +struct Node { + parent: Option>>, + value: u64, +} +``` + +With a `PyClass` implementation like `Encoder`'s, `Node` sets +`Py_TPFLAGS_HAVE_GC` and installs `tp_traverse` and `tp_clear`. Lifecycle rules +are: + +| Operation | Invariant | +| --- | --- | +| Allocate | Write `C` into an untracked object, publish ready, then call `PyObject_GC_Track`. | +| Traverse | Borrow `&C`; only call `visitproc`; never invoke Python or block. | +| Clear | After unreachability, lend `&mut C`; take and `drop_with(ctx)` each edge once; later clears are no-ops. | +| Deallocate | Untrack first. Contain any required clear independently; then commit dropping, drop `C` once, run allocation/type guards, and report the first panic. | + +Types are immutable, final, and non-instantiable unless they define `tp_new`. +Finality preserves the `METH_METHOD` defining-class link used to recover typed +module state. Rust factories may always use `new_instance`. + +`#[pymethods]` processes four restricted kinds of entries: + +- `#[pyfunction]` creates a normal instance method, a `PyCMethod` trampoline, + and a `ClassMethodDef` whose state parameter is `&C::State`. +- `#[getter]` creates a read-only `ClassGetSetDef`. +- `#[setter]` creates the setter half of a `ClassGetSetDef`. +- `#[new]` creates a `tp_new` trampoline which parses arguments, constructs + `C`, and a `ClassNewDef` which moves it into a new Python allocation. + +All generated slots retain CPython's existing ABI and enter the common context +and panic boundary. The resulting `Py>` belongs in module state; +`add_type` accepts its safe untyped upcast. + + +#### Argument parsing + +Argument parsing exposes a small PyO3-like signature syntax: + +```rust +#[pyfunction(signature = (data, value = 1, /))] +fn checksum<'a, 'py>( + ctx: &ThreadContext<'py>, + state: &ChecksumState, + data: Borrowed<'a, 'py, PyBytes>, + value: u32, +) -> PyResult { + // ... +} +``` + +The macro checks parameter names and emits a static `ParamSpec`. Supplied +values use `FromPyObject` to extract Rust types; defaults are Rust expressions. + +Vectorcall binding uses a fixed stack array and allocation-free keyword +matching. Slots become `Borrowed<'args, 'py, PyAny>` without refcount traffic; +`CallbackArgs` lends `'args`, while `with_current` supplies `'py`. Constructor +tuple/dict parsing fills the same slots and shares conversion code. + +Too many, duplicate, unexpected, or missing arguments raise `TypeError`. The +initial grammar grows only as modules require more features. + + +#### Testing + +CI should run `cargo fmt` and +[Clippy](https://github.com/rust-lang/rust-clippy). + +A new `xxtestrustapi` module will be introduced to the normal Python test suite suite to ensure the correctness of the +implementation and check invariants of the Rust code. + +CI will run a mechanical check that every Python-touching safe `c_api` +wrapper must accept `ThreadContext`; an operation without one needs an audited +thread-state-free justification. + +### Appendix: Open decisions and future work + +The following items are intentionally kept out of the normative API sections: + +- **Subinterpreters.** Future support requires an interpreter-identity model + for contexts, owning handles, module state, and process-global Rust values. +- **Buffer protocol.** No safe `PyBuffer` API is included until the design + distinguishes read-only and writable exporters; specifies contiguity, + format, alignment, ownership, and release on every exit path; and prevents + Rust slices from racing with Python mutation or escaping across detach and + reentrant calls. Copying from an exact immutable bytes object is the safe + interim input path. +- **Interpreter-aware locking.** The design must either provide a lock which + cooperates with attachment, the GIL, and free-threaded stop-the-world pauses, + or prohibit blocking, detaching, calling Python, and reaching safepoints + while an ordinary Rust lock guard is held. Poisoning and traversal behavior + also require an explicit policy. +- **Detached destruction.** Consider a deferred decref queue if detached + destruction is needed in practice. +- **Explicit-state C optimization.** Benchmark explicit-`PyThreadState *` + variants for TLS-heavy hot paths; this would not change the safe Rust API. +- **Rust runtime and panic strategy.** The initial API requires `std` and + `panic=unwind` so generated callbacks can contain panics, complete lifecycle + cleanup, and report errors. A `panic=abort` build remains ABI-safe but cannot + provide that recovery behavior. Any future `no_std` configuration must use + panic-abort or provide another complete no-unwind boundary. From dbf17bc599c75fe8739bcfd7abd405491020bcae Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Sat, 19 Sep 2026 13:14:30 -0700 Subject: [PATCH 02/16] Rewrite with new model --- RUST_API.md | 1222 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 1004 insertions(+), 218 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index e5c9acbdfa0d4b9..da5bbbe19243ec4 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -16,7 +16,8 @@ those guarantees when implementing CPython modules in Rust. In priority order, the API should: -1. be sound on GIL and free-threaded builds; +1. be sound on GIL and free-threaded builds, including subinterpreters with + separate GILs; 2. support every PEP 11 platform; 3. approach equivalent C performance where safety permits; 4. feel familiar to PyO3 and C API users; and @@ -27,8 +28,9 @@ obligation. No panic may escape a non-unwinding C ABI. ### Non-goals -Full C API parity and subinterpreter support are out of scope. Wrappers are -added as modules need them; generated modules reject subinterpreter imports. +Full C API parity, a stable ABI, and sharing of Python objects +between interpreters are out of scope. Wrappers are added as modules implemented in Rust need +them. ### Specific guidelines @@ -40,17 +42,19 @@ added as modules need them; generated modules reject subinterpreter imports. ### API design 1. Bindings and the FFI boundary -2. Managing the thread context -3. Python object handles and garbage collection -4. Exceptions and error handling -5. Concrete object abstractions -6. Rust/Python conversions -7. Module definition -8. Function definition -9. Class and method definition -10. Argument parsing -11. Testing -12. Open decisions and future work +2. Interpreter identity and lifetime +3. Managing the thread context +4. Python object handles and garbage collection +5. Deferred reference release +6. Exceptions and error handling +7. Concrete object abstractions +8. Rust/Python conversions +9. Module definition +10. Function definition +11. Class and method definition +12. Argument parsing +13. Testing +14. Open decisions and future work #### Bindings and the FFI boundary @@ -69,99 +73,304 @@ pub struct PyObject(UnsafeCell<_object>); ``` All C calls pass through a private `c_api` module. A safe operation that may -touch Python requires `ThreadContext`, an abstraction over the thread-state; an operation without one needs an -audited proof that its entire call path is thread-state-free. Existing C APIs -may obtain the current state internally. +touch Python requires a borrow of `ThreadContext`, or the private restricted +capability appropriate to traversal or reference release. Shared context +access is reserved for audited leaf operations; operations that can run +callbacks or change attachment require exclusive access. The scope protocol +below maintains attachment without a TLS check at every leaf operation. +An operation without a capability needs an audited proof that its entire +call path is thread-state-free. Ordinary `Drop` only submits releases; it +never obtains a capability to decref synchronously. Existing C APIs may +still obtain the current state internally. + +#### Interpreter identity and lifetime + +Each interpreter lifetime has a private `InterpreterKey`, represented by a +nonzero 64-bit generation number. CPython allocates it from a process-wide +sequence that survives `Py_FinalizeEx` and reinitialization; exhaustion fails +interpreter creation rather than reusing a key. A main interpreter created +after reinitialization receives a new key too. Existing interpreter IDs alone +do not satisfy this contract: `Python/pystate.c` currently resets `next_id` +when enabling the interpreter registry. + +The key records the interpreter under whose API contract a reference was +acquired and is usable, not which interpreter allocated its storage. CPython +may supply process-global constants or static types to several interpreters. +An accessor called in interpreter B may return a B-associated handle to such +an object even if its storage originated in the main interpreter. An existing +A-associated handle cannot be relabeled B merely because the pointer is +immortal or is equal to another pointer. Only an audited CPython contract can +authorize sharing; ordinary cross-interpreter transfer reconstructs a new +object through a separately audited conversion. + +There is no generic object-to-interpreter lookup. `PyObject` headers do not +carry this association, and the free-threaded `ob_tid` describes refcount +thread ownership, not interpreter identity. Generated glue obtains the key +from the attached thread state's interpreter and preserves it through handle +conversions. Reading the current interpreter and its cached key requires no +allocation, registry search, or lock. No `PyObject` header change is required. + +Identity does not prove liveness. Each active outermost Rust entry holds an +interpreter-finalization lease, including while temporarily detached. Nested +entries for the same protected thread state reuse that protection. The lease +is held by private entry glue, cannot escape to user code, and is released on +normal return and unwind after all scoped values have been consumed, queued +releases have been drained or transferred, and panic reporting has finished. Safe +APIs must not initiate or synchronously wait for destruction of that +interpreter while holding its lease. A stored `Py` does not hold a lease: +otherwise module state could prevent the shutdown needed to destroy itself. + +The lease follows the lifetime protection of +[PEP 788 interpreter guards](https://peps.python.org/pep-0788/), using a new +private stack-based entry API to avoid allocating a public guard on each +callback. Acquisition must synchronize with finalization's admission barrier; +checking a finalizing flag before incrementing a counter is insufficient. +Normal entry fails before exposing Rust references if admission has closed. +Generated callbacks that CPython legitimately invokes during finalization +instead receive private cleanup authorization from the finalizing thread. +This also covers synchronous reentrant callbacks caused by those finalizers. +Their contexts are limited to that callback's lifetime and cannot request +explicit detachment or unrestricted foreign-library interoperation. Audited +synchronous C calls may temporarily detach internally only if the cleanup +authorization protects their state through restoration and finalization +cannot advance until the call returns. +Neither user code nor an arbitrary foreign thread can manufacture cleanup +authorization. Traversal has its separate restricted visitor capability and +cannot acquire a general context merely to bypass its restrictions. + +The initial API creates contexts only at generated C entries. A future native +thread entry API must select an explicit interpreter key, acquire lifetime +protection before attachment, and report shutdown failure without a Python +exception when detached. It must not use `PyGILState_Ensure` to implicitly +select the main interpreter. Raw C callers remain responsible for presenting +valid callback inputs and obeying CPython's interpreter contracts. #### Managing the thread context -`ThreadContext<'py>` proves that the current OS thread has an attached CPython -thread state for `'py`: +`ThreadContext<'py>` is a scoped, exclusive capability for one exact CPython +thread state on the current OS thread. Whenever safe code can use the +context, that state is attached and protected against finalization. It is +not enough for some state in the same interpreter to be attached: exception +state and other thread-local CPython state belong to the exact thread state. ```rust pub struct ThreadContext<'py> { ptr: NonNull, - detach: Option>, + interpreter: InterpreterKey, + scope: NonNull, // private, stable storage in the entry guard #[allow(clippy::type_complexity)] _marker: PhantomData<(fn(&'py ()) -> &'py (), *mut ())>, } - -struct DetachPermit<'py> { - _marker: PhantomData<&'py mut RustEntryGuard>, -} ``` -The invariant marker makes the context `!Send + !Sync`. Neither type is -`Copy` or `Clone`, and only generated entry glue constructs them. +The function marker makes `'py` invariant, and the raw-pointer marker makes +the context `!Send + !Sync`. It is neither `Copy` nor `Clone`; only generated +entry glue constructs it and lends it to author code. `Bound` and `Borrowed` +are also invariant in `'py`. This is a fresh entry-scope brand, not a lifetime +chosen once per interpreter: separate entries cannot mix scoped values +directly, even in the same interpreter. Unbind and checked bind explicitly +move ownership between scopes. -Each FFI entry increments a depth stored on the actual `PyThreadState` and -holds an unwind-safe `RustEntryGuard` for the full callback. Process-wide -storage is required because separate `cdylib`s may contain separate Rust TLS. -Only depth one receives a `DetachPermit`; reentrant callbacks remain attached -but cannot detach. `RustEntryGuard::drop` restores the exact previous depth on -normal return and unwind. +There are two classes of Python access: -Entry uses a higher-ranked closure so callers cannot choose `'py = 'static` or -return an attached value: +| Context argument | Permitted operations | Examples | +| --- | --- | --- | +| `&ThreadContext<'py>` | Audited leaf operations that preserve attachment and cannot invoke arbitrary Rust or Python callbacks, run deallocators, or reach a suspending/reentrant safepoint. | Checked binding, ownership-preserving casts, exact immutable bytes access, and an audited incref. | +| `&mut ThreadContext<'py>` | Operations that may execute Python, call user code, allocate through a potentially reentrant path, decref, drain releases, detach, or switch states temporarily. | Generic extraction, object creation, attribute access, explicit destruction, and `detach`. | + +The classification covers the entire call path, including failure handling, +allocator hooks, synchronization, and incidental cleanup. A shared-context +operation returns native errors if reporting a Python error would require +exclusive access. A new wrapper defaults to exclusive access until its leaf +contract has been audited. A blocking acquisition is not a leaf merely +because its successful fast path is short. Attachment does not itself +protect object contents against concurrent mutation on a free-threaded build. + +Author functions may accept `&mut ThreadContext` and reborrow it as shared +for consecutive leaf operations. Taking `&mut ThreadContext` does not make +it unavailable automatically. A private wrapper suspends the capability only +around the actual potentially reentrant C call or closure; it never passes +that suspended context to author code, trait implementations, or callbacks. +Argument conversion and return conversion implementations receive an +available context and use the same wrappers as other Rust code. + +##### Entry and attachment transitions + +This design requires new private CPython support. Rust borrowing alone +cannot stop a different safe Rust library from calling `PyEval_SaveThread` +without borrowing this context. Checking only when a generated callback is +entered is also insufficient: a detached Rust closure can call scoped TLS +without crossing a generated C entry. + +CPython maintains a stack of Rust scope records per OS thread, shared across +all extension libraries and all interpreters on that thread. A record is +**available** while its context can be used, or **suspended** while a private +wrapper holds the exclusive borrow and exposes no access to that context. +At most one record is available on an OS thread. Nested entries are admitted +only while all enclosing records are suspended; they create a fresh scope +for the state actually attached at that entry. Scope depth on an individual +`PyThreadState` is insufficient when C code switches interpreters. + +The required private operations have these contracts; their names and C +layout are implementation details: + +1. **Enter:** verify the actual attached state and execution phase, acquire + or reuse interpreter lifetime protection, and register an available record + in stable, private stack storage. Entry cannot publish a context before + all these steps succeed. A per-interpreter lease does not by itself keep + an individual thread state alive: a registered entry also protects its + exact state from deletion until restoration and exit. +2. **Suspend:** while holding `&mut ThreadContext`, mark its record suspended + before invoking a potentially reentrant operation. Native preflight checks + leave attachment and registration unchanged on refusal and do not create + a Python exception. No arbitrary callback occurs between preflight and + committing suspension. The private restoration guard retains the exact + original state, record, and lifetime protection. + Suspension removes access to the Rust capability; it does not broaden + the C operations permitted by the execution phase. Cleanup authorization + and its transition restrictions remain in force. +3. **Transition:** CPython's attach, detach, swap, and thread-state deletion + paths reject any transition that invalidates an available record. They + consult the OS-thread registration even when there is currently no + attached state, and cover both public and internal transition paths. + Deletion additionally respects every live thread-state protection, + including suspended records and active consumers, even when attempted + from another OS thread. The deleting thread's own scope stack is not + sufficient. Destruction cannot synchronously wait for a protected entry + on its own call stack to finish. +4. **Resume:** require nested records to have exited and the exact original + state to be attached before making the outer record available again. + Restore it on normal return and Rust unwind according to the operation's + audited contract. If a foreign call violates its restoration contract, + fail without exposing a stale context; do not silently attach over an + unknown state. Failure to restore safely is fatal. +5. **Exit:** after author code and scoped handles are finished, mark the + record unavailable and service or transfer its releases while retaining + the sink, ancestry, and lifetime protection for reentrant cleanup. Remove + the record only after its sink is empty. Cleanup and panic reporting + precede release of lifetime and thread-state protection. + +Registration and restoration guards are private and cannot be returned or +forgotten by safe author code. Forgetting a public handle may leak a reference +but cannot remove attachment protection or strand a suspended registration. +Stable record addresses are established before registration, without requiring +an allocation for each entry. The entry guard, rather than the public context, +owns the record and its associated release sink. + +A controlled Rust wrapper checks detachment permission before calling +`PyEval_SaveThread`, so that C API needs no new recoverable failure result +for this path. Legacy C transitions that bypass the protocol while an +available record exists must fail-stop before changing attachment; silently +continuing would invalidate safe Rust's proof, and waiting on the same thread +would deadlock. A new private checked transition API may return a native +status instead. Scope-entry protocol violations likewise fail before exposing +Rust values. These requirements must be implemented in CPython before leaf +operations can omit attachment checks; this is not a safe model for an +unmodified runtime. + +Audited internal parking for stop-the-world or synchronization can be +exempted only if no arbitrary callback or Rust access can run until the exact +state is restored, and the leaf operation exposes no attachment-dependent +access during that interval. Otherwise it must use suspension. This audit +includes free-threaded safepoints and object critical sections, not only +`Py_BEGIN_ALLOW_THREADS`. + +Entry uses a higher-ranked closure so callers cannot choose `'py = 'static` +or return a scoped value. Its conceptual private signature is: ```rust -impl RustEntryGuard { - fn context<'scope>(&'scope mut self) -> ThreadContext<'scope> { - let detach = self.is_outermost().then(|| DetachPermit { - _marker: PhantomData, - }); - ThreadContext { - ptr: self.ptr(), - detach, - _marker: PhantomData, - } - } -} - -// SAFETY: called only from generated C callbacks with an attached state. +// SAFETY: only generated C callbacks with an attached state and valid inputs +// may enter; the implementation performs registration and lifetime admission. unsafe fn with_current( f: impl for<'py> FnOnce(&mut ThreadContext<'py>) -> R, -) -> R { - // SAFETY: guaranteed by the caller. - let ptr = unsafe { current_attached_tstate() }; - let mut entry = RustEntryGuard::enter(ptr); - let mut ctx = entry.context(); - f(&mut ctx) -} +) -> Result; ``` -The guard borrow keeps it alive through the `'py` lifetime; `for<'py>` makes the lifetime -fresh; and `R` cannot depend on it. Generated wrappers convert attached results to -raw C values before returning. Macros reject author functions that request a -specific lifetime such as `'static`. +`for<'py>` makes the lifetime fresh and `R` cannot depend on it. Generated +wrappers convert scoped results to raw C values before returning. Macros +reject author functions that request a specific lifetime such as `'static`. +Callback inputs also have a separate owner lifetime, described under argument +parsing. The actual entry glue keeps its private guard through parsing, +conversion, unwind handling, release processing, and exception reporting. -Callback arguments have a separate owner lifetime, described under argument -parsing; constructing that owner is unsafe and confined to generated glue code. +`EntryError` is native. Normal admission failure is translated by private +callback-specific reporting glue with its own valid attachment and lifetime +authorization; it must not manufacture a general context after admission +closed. If the callback has neither normal nor cleanup/reporting +authorization, or violates the scope protocol, it cannot safely continue. +No error path may consult a stale context to raise an exception. -Detachment requires `&mut ThreadContext` and the outermost-entry permit: +##### Detachment, reentry, and foreign wrappers ```rust impl<'py> ThreadContext<'py> { - pub fn detach(&mut self, f: F) -> PyResult - where - F: FnOnce() -> R + Send, - R: Send, - { - self.detach_impl(f) - } -} -``` + pub fn detach( + &mut self, + f: impl FnOnce() -> R, + ) -> Result; -The exclusive borrow prevents use of that context while detached. The `Send` -bounds exclude attached handles from the closure and its result. Missing -permits produce `RuntimeError` without detaching. Internally, a guard uses -`_PyThreadState_Detach` and `_PyThreadState_Attach` to restore the exact state -even if `f` panics: + pub fn interop( + &mut self, + f: impl FnOnce() -> R, + ) -> Result; -```rust -let output = ctx.detach(move || compress_without_python(input))?; + pub fn drain_releases(&mut self); +} ``` +`detach` checks the execution phase, suspends the record, calls +`PyEval_SaveThread` or its private equivalent, executes `f` on the same OS +thread, and restores the saved state before resuming the record. Its private +guard does the same restoration if `f` panics. The interpreter lease and +thread-state protection span the entire detached interval. A refusal returns +a native `DetachError` before detaching; a caller can subsequently translate +it through an available context. Cleanup entries refuse explicit detachment. +There is no outermost-entry permit: a normal nested callback may detach when +all enclosing contexts are suspended and it has lifetime protection. + +`interop` suspends the record without initially detaching. Its closure can +use a foreign safe wrapper that manages its own attachment, including +switching interpreters. That wrapper must restore the incoming state before +returning or unwinding; the boundary verifies exact restoration before +resuming the context. Cleanup entries refuse unrestricted interoperation. +Unsafe foreign calls retain their ordinary C and Rust safety obligations. +This boundary protects this API's contexts; it does not repair a foreign +wrapper's own unsound attachment tokens or reference lifetimes. Foreign +wrappers remain responsible for their capabilities and C API contracts. + +Neither closure receives the borrowed context. Neither needs `Send` bounds +on its captures or result: both execute on the same thread, and the proof is +exclusive access to the capability, not an assertion about captured values. +Opaque scoped handles and independently safe Rust data may be moved into or +returned from the closure. Accessing Python through a handle still requires +an available matching context. Binding it in a fresh callback requires an +explicit unbind and checked bind. + +This addresses the mechanisms in PyO3 issues +[#2141](https://github.com/PyO3/pyo3/issues/2141) and +[#3640](https://github.com/PyO3/pyo3/issues/3640). `SendWrapper` can make a +non-`Send` value transportable while allowing access on its originating +thread, and a capture-free function can reach scoped TLS. Neither property +proves attachment. In this API, placing a shared context reference in scoped +TLS prevents an overlapping exclusive borrow for detachment or reentrant +operations. A sound mutable scoped-TLS abstraction or `RefCell` must retain +its exclusive loan throughout the call; attempted reentrant borrowing fails +rather than yielding an alias. TLS may make opaque handles reachable while +detached, but cannot supply a usable suspended context. Foreign detachment +that does not borrow the context is covered by CPython's transition gate. + +The same rule prevents an outer context for interpreter A from being used by +a TLS hook invoked while a C call has switched to interpreter B. The call +holds the exclusive borrow of A's context for its entire duration; B's +callback receives a distinct scope. Merely promising to restore A when the +C call eventually returns would not be sufficient. + +A Python-touching implicit operation cannot bypass these rules. Handle +`Debug`, equality, hashing, indexing, or iterator operations either use only +Rust metadata or require an explicit context through a named method; no +implicit operation performs Python `repr`, equality, or protocol dispatch. +Ordinary owning-handle `Drop` only queues releases, as specified below. + #### Python object handles and garbage collection Three private generic handle representations distinguish borrowing, ownership, @@ -170,38 +379,119 @@ and attachment: | Handle | Owns a reference | Lifetime | Auto traits | Drop | | --- | --- | --- | --- | --- | | `Borrowed<'a, 'py, T>` | No | owner `'a` and attachment `'py` | `Copy`, `!Send`, `!Sync` | none | -| `Bound<'py, T>` | Yes | attachment `'py` | `!Send`, `!Sync` | decref while attached | -| `Py` | Yes | unbound, for stored state | `Send`, `Sync` | checked policy below | +| `Bound<'py, T>` | Yes | guarded entry `'py` | `!Send`, `!Sync` | enqueue in entry release sink | +| `Py` | Yes | unbound, for stored state | `Send`, `Sync` | enqueue by interpreter key | + +The two `Borrowed` lifetimes are independent: the reference owner supplies +`'a`, while `ThreadContext` supplies interpreter liveness and permission to +access the object within `'py`. `Py` may cross threads, but touching its +object still requires an active context for its associated interpreter. + +`Py` stores a non-null object pointer and an `InterpreterKey`. `Bound` +stores an object pointer and a private pointer to its entry's release sink, +plus the invariant scope brand and thread-affinity marker. The sink records +the interpreter key and is valid for all of `'py`, including detached and +suspended intervals. Unbinding copies that key into the resulting `Py`. +A borrowed handle needs only the pointer plus lifetime markers because its +checked construction binds it to the context's scope. On a 64-bit target, +both owning forms can remain two words; padding is target-dependent. + +The scope brand proves association and lifetime, not independent permission +to touch Python. A `Bound` or `Borrowed` can exist while its context is +suspended, but Python access still requires an available context. They cannot +create a context or expose a safe raw-object dereference on their own. + +Every safe `Py` operation that accesses the object, including binding, +downcasting, cloning a reference, conversion, and explicit destruction, must +first compare the handle's key with the context's key. Mismatch is a native +`InterpreterMismatch` error and must be detected before dereferencing the +object, even to inspect its type or refcount. This also rejects an expired +handle when a later interpreter occupies the same address. The active +context protects the matching interpreter for the whole operation. + +The binding signature is: + +```rust +impl Py { + pub fn bind<'a, 'py>( + &'a self, + ctx: &ThreadContext<'py>, + ) -> Result, InterpreterMismatch>; +} +``` -The two `Borrowed` lifetimes are independent: an owner keeps the object alive -for `'a`, while `ThreadContext` permits access for `'py`. `Py` may cross -threads, but touching its object still requires an active context. +Scoped operations accept the same invariant `'py` on all handles and the +context, avoiding repeated dynamic identity checks. Leaf access also needs +no repeated attachment check: entry registration and transition enforcement +maintain that invariant until the next exclusive operation. To propagate a +native mismatch through a Python callback, explicitly raise an exception on the +current context; no error conversion implicitly consults ambient attachment. Moving among the forms is explicit: ```rust let stored: Py = exception_type.unbind(); -let current: Borrowed<'_, 'py, PyType> = stored.bind(ctx); +let current: Borrowed<'_, 'py, PyType> = stored.bind(ctx) + .map_err(|err| err.raise(ctx))?; let retained: Bound<'py, PyType> = current.to_owned(ctx); ``` Neither owning handle implements `Clone`; `clone_ref(ctx)` and `to_owned(ctx)` make each incref explicit. Raw borrowed and owned-pointer constructors are private. Their glue proves non-nullness, interpreter identity, -liveness, and the C ownership contract before constructing a handle. +liveness, and the C ownership contract before constructing a handle. Merely +having an attached thread state does not validate an arbitrary raw pointer. +Upcasts, unbinding, and ownership transfers preserve the key; generated +callback arguments and audited API results acquire the current scope's key. + +All Rust references into Python-owned memory, including byte slices, class +payloads, and module state, are bounded by both the owner's lifetime and the +guarded entry scope. A `Py` in static Rust storage must not yield a static +`&[u8]`, `&C`, or `&S`. A view requiring continuous attachment additionally +borrows the context, excluding exclusive operations for its entire lifetime. +A Rust-only `Send + Sync` payload or immutable bytes view may span detachment +only under an audited contract retaining its owner and entry protection. +This does not authorize Python-field access without a matching context. +Safe code cannot return Python-backed references from the higher-ranked entry. + +An attachment borrow is not a lock or a proof of exclusive object access. +Another thread can mutate a Python object, and a foreign wrapper can reenter +Python on the same state without detaching. Therefore neither the transition +gate nor `&ThreadContext` justifies a Rust slice into mutable container storage, +an unsynchronized `&Cell<_>` payload, or an exclusive reference to published +Python memory. Such views need separate ownership and synchronization proofs. +`Send + Sync` remains mandatory for shared class and module payloads. Owning handles implement consuming explicit destruction: ```rust -object.drop_with(ctx); +object.drop_with(ctx)?; ``` -`drop_with` uses `ManuallyDrop` to avoid a second automatic drop. Ordinary -`Drop` cannot receive a context, so it checks attachment with -`PyThreadState_GetUnchecked`: attached drops decref; detached drops leak and -record a Python-free, non-reentrant, non-allocating, non-panicking diagnostic. -Leaking is a programming error but remains memory-safe. `Bound` normally -cannot reach this case because it is attachment-bound and `!Send + !Sync`. +`Bound::drop_with(&mut ctx)` uses the shared scope brand and returns `()`. +`Py::drop_with(&mut ctx)` returns `Result<(), InterpreterMismatch>`; on +mismatch it consumes the handle through ordinary queued `Drop` and reports +the native mismatch without touching the foreign object. A Python callback +maps that error with `err.raise(ctx)`. On a match, explicit destruction uses +the exclusive-call protocol to release the reference directly, and consumes +the ownership exactly once. As elsewhere, `ctx` that is already a mutable +reference is passed directly, without adding another `&mut`. + +Ordinary `Drop` never decrefs, drains releases, attaches, or switches states, +even when the current thread happens to be attached to the right interpreter. +A direct decref could invoke a finalizer which reenters Python or detaches +while a shared context remains reachable through scoped TLS. Actual-attachment +checks in `Drop` would not establish the exclusive borrow needed for that +execution. `Bound::drop` instead appends to its entry's release sink; `Py::drop` +submits by key, with an optional matching live sink fast path. Both transfer +ownership without reading the object's header or invoking Python. + +Explicit release and queue draining preserve the incoming Python exception. +Generated Rust deallocators contain panics and preserve exception state while +reporting an unraisable error. `Drop` must not unwind, including when Rust is +already unwinding. Detached destruction is supported behavior; allocation +failure or a closed/expired interpreter uses the abandonment policy below, +rather than a decref in the wrong execution context. `PyAny` is the erased object marker. More specific markers, such as `PyBytes` and `PyType`, have the same transparent layout, so upcasts to `PyAny` do not @@ -209,54 +499,264 @@ touch the refcount. There is intentionally no `&mut PyAny`: Python objects have interior mutability and can be changed through aliases, especially in a free-threaded build. -Cyclic GC tracking is handled for modules and classes and described in their respective sections. +Cyclic GC tracking is handled for modules and classes and described in their +respective sections. A tagged `Py` stored in pure Rust state may remain +opaque even if its interpreter expires. Publishing a handle as a traced +Python edge additionally requires association with the containing object's +interpreter, both at construction and every later mutation. A binding check +at eventual use is too late to protect the collector. + +#### Deferred reference release + +Each Rust entry has a private release sink with a small inline buffer. +`Bound::drop` appends through audited interior mutability in that sink, with +no allocation, TLS lookup, global lock, or Python call while capacity remains. +The entry owns the buffer; no queue node points into the dropped handle. +When the buffer fills, it transfers a batch to the interpreter's native +queue, or obtains Python-free overflow storage. It must never make room by +decrefing in `Drop`. If both options fail, retain every previously accepted +reference in the sink and abandon only the incoming, unaccepted reference +under the policy below. Acceptance includes insertion into an inline sink; +later allocation failure cannot revoke it. No mutable borrow of sink storage +or queue lock spans a callback. + +The sink remains valid through suspended contexts and Rust unwinding. +`Py::drop` may use a CPython-owned OS-thread lookup of a live sink with the +same generation key, including a suspended outer entry's sink, without +inspecting Python memory. Otherwise it submits to the interpreter queue. +This optimization carries the same admission and ownership guarantees as +ordinary submission; a stale sink cannot survive entry exit. Entry exit +drains or transfers every buffered reference before retiring the sink. If +transfer fails, it drains the retained entries under private release +authorization and lifetime protection; it cannot abandon an accepted batch. +After external admission closes, authorized cleanup sinks use their private +cleanup route for overflow too, or retain accepted entries for local draining. +That route uses accepting cleanup storage or Python-free overflow allocation; +it must not recursively resubmit to the same full inline sink. + +CPython owns one native release queue per interpreter lifetime. The following +new private API is a required part of this design, not an existing C API: + +```c +typedef enum { + _Py_RELEASE_QUEUED, + _Py_RELEASE_CLOSED, + _Py_RELEASE_EXPIRED, + _Py_RELEASE_NO_MEMORY, +} _PyDeferredReleaseResult; + +_PyDeferredReleaseResult _PyObject_TryDeferDecRef( + _PyInterpreterKey interpreter, + PyObject *owned_reference); +``` + +The caller supplies exactly one owned reference associated with the key, or +the opaque pointer remaining from such ownership after the interpreter has +expired. Only `_Py_RELEASE_QUEUED` consumes that reference. The queue takes +over the existing reference without an incref; the producer must not also +decref it. Failure consumes nothing, sets no Python exception, and does not +inspect the object. Keys and pointers are private to the safe Rust wrapper; +the raw C API retains these ownership and association preconditions. + +Submission is callable from any native thread: detached, attached to another +interpreter, or after `Py_FinalizeEx`, while the CPython library remains +loaded. It must not read the object's header, change its refcount, invoke +Python, attach a thread state, or panic. Queue storage and diagnostic paths +must have an audited Python-free allocation/synchronization contract. A +short native lock or allocation may contend, but submission must not wait +for the target interpreter to execute, acquire its GIL, or finish finalizing. +Pooled queue nodes and batched draining amortize submission overhead. A +node owns its copied pointer; it must not refer to the Rust handle's former +stack or container location. + +A process-lifetime registry maps generation keys to live queue endpoints. +Registry synchronization survives runtime reinitialization. Lookup, admission, +and any notification that accesses interpreter or thread-state memory are +protected against interpreter deletion as one protocol. No unprotected +`PyInterpreterState *` escapes that protocol. Retirement removes the endpoint +before freeing interpreter memory; absent keys are expired and never alias a +new interpreter. In-flight producers protect only submission bookkeeping, +not an indefinitely retained Python handle. Notification must remain reliable +when the ordinary pending-call queue is full: an accepted release cannot +disappear merely because scheduling a wakeup failed. + +Before removing a normal batch, the consumer acquires one finalization lease +for that batch or reuses an enclosing entry's protection. It retains this +protection through decrefs, reentrant callbacks, and batch cleanup, including +any temporary detach inside a C deallocator. Shutdown accounts for these +active consumers as well as producers: an empty queue does not prove that a +previously removed batch has finished. This is one lease per batch, not per +reference. The final shutdown drain instead uses cleanup authorization. + +The consumer takes a batch out of the queue and drops its native locks before +decrefing any object. Each queued reference is released exactly once while +attached to its associated interpreter with permission to execute +deallocators. Until then the queued ownership is an external strong reference +for GC purposes; moving an edge out of a GC field must remove that field's +edge before publishing the release. The consumer preserves the incoming +exception and tolerates finalizers that reenter Rust or submit more releases. +It must not hold an incompatible Rust payload borrow across a decref. + +Draining requires the exclusive-call protocol, either through +`ctx.drain_releases()` or private release authorization between scopes. +CPython checkpoints defer draining while any context on the current OS +thread is available; being attached to the right interpreter is insufficient. +An explicit drain suspends its context for the whole batch and restores it +after all reentrant callbacks have finished. Eligible entry boundaries retain +the entry lease while servicing releases after the scope ends. + +Draining uses bounded batches during normal execution and also runs as part +of interpreter shutdown. It must not depend solely on another call into a +Rust module. Deferred release promises neither destruction before `Drop` returns +nor destruction on the submitting OS thread. Without target execution it can +retain resources until shutdown; native resources that need prompt release +should expose an explicit close operation with a context. + +Shutdown uses the following admission protocol: + +1. While open, the queue accepts references and can be drained normally. +2. Finalization closes admission of new external Rust-entry leases and lets + existing entries and admitted normal consumers, including their + detach/reattach operations and nested callbacks, finish while the + interpreter is intact. It continues servicing releases under cleanup + authorization, releasing its GIL and any locks needed by those entries + while waiting. New normal consumer leases use the same closed gate. +3. Queue admission closes atomically against producers. Already admitted + submissions finish and all accepted references are drained while the + interpreter can still execute deallocators. Waiting and + decrefing occur without registry/queue locks and without keeping the world + stopped across work that may need another thread. This must integrate + with `Python/pylifecycle.c`'s pre-finalization loop, not be inserted after + that loop returns holding its pending-call lock with the world stopped. +4. Authorized finalization callbacks still enqueue their ordinary drops into + a cleanup sink. After external admission closes, CPython accepts this + sink's releases only under the finalizing thread's private cleanup + authorization and matching key. Drain cleanup sinks and their reentrant + submissions to completion before tearing down object-supporting state; + this also covers drops produced by later module and type cleanup. + Explicit release still requires exclusive context access. New external + submissions receive `CLOSED`; after endpoint retirement they receive + `EXPIRED`. No accepted queue or sink entry survives interpreter teardown. + +An ordinary `atexit` drain or a check-then-enqueue sequence does not establish +this protocol. The final drain's reentrant callbacks remain under cleanup +authorization, and may not acquire a new normal entry lease or request +explicit detachment. Emptying one batch is not sufficient if its callbacks +created more cleanup releases. The any-thread API may route a matching call +from an authorized cleanup scope to that scope's sink; it must never infer +authorization merely from being attached to a finalizing interpreter. + +On `CLOSED`, `EXPIRED`, or `NO_MEMORY`, Rust `Drop` deliberately abandons the +unreleasable reference and records a Python-free, non-reentrant, +non-allocating, non-panicking diagnostic. It never retries by dereferencing +the pointer or decrefing in a different interpreter. This policy can leak +resources, but cannot make an expired object accessible through safe Rust. +The same rule applies to native shutdown and TLS destructors. Safe Rust can +also forget a handle, so correctness cannot rely on every destructor running. + +This queue guarantees release of accepted references before teardown, not +reclamation of every `Py` retained outside the interpreter. Releasing +those references during shutdown would require registered, revocable +ownership records; guaranteeing enqueue under allocation failure would +require reserved storage. Neither cost is imposed on every owning handle in +this design. The common scoped drop uses the inline sink; explicit destruction +can avoid queue traffic when prompt release matters. Deferred release changes +finalizer timing even on attached threads, and batching may retain memory +longer. Benchmark the inline path, overflow, and unbound `Py` submissions; +there is no claim that queuing every implicit drop is free. + +On platforms supporting `fork`, the registry, sinks, scope records, and +lease counts must participate in CPython's before/after-fork protocol. +Detached producers must be quiesced or synchronized by that protocol; +stopping attached Python threads alone is insufficient. The child must +reinitialize native synchronization, preserve accepted releases for surviving +interpreters, remove counts and records from vanished threads, and retire +endpoints for interpreters removed after fork. Records and restoration guards +on the surviving thread remain valid. Fork preparation cannot wait while +holding a lock that a paused producer needs. The queue is not ready for +general use until these child and parent paths are defined and tested. #### Exceptions and error handling -Errors use a non-owning marker for the current thread's pending exception: +Errors own their exception rather than marking a pending exception in an +ambient thread state: ```rust #[must_use] -pub struct PyErrRaised { - _priv: PhantomData<*mut ()>, +pub struct PyErr { + exception: Py, } -pub type PyResult = Result; +pub type PyResult = Result; ``` -`PyErrRaised` is `!Send` and `#[must_use]`. Built-in and custom exception types -provide `raise(ctx, message)`, so Rust code can use `?` without copying the -Python exception: +`PyErr` has the ownership and interpreter association of `Py`. It can move +between Rust threads, but can be inspected or restored only through a context +for its associated interpreter. It is not tied to the thread state on which +the exception was created. Dropping or replacing it enqueues its reference; +recovering from an error in Rust therefore does not leave a hidden exception +pending on the thread state. No ambient `PyErrRaised` token or freshness +tracking is needed. + +Built-in and custom exception types provide `raise(&mut ctx, message)` which +constructs and returns an owned `PyErr`. The name describes the intended +Python error; the exception is not left pending until the generated boundary +restores it. Exception construction may allocate and reenter, so it follows +the exclusive-call protocol: ```rust -fn checked_size(ctx: &ThreadContext<'_>, size: usize) -> PyResult { +fn checked_size(ctx: &mut ThreadContext<'_>, size: usize) -> PyResult { i32::try_from(size) .map_err(|_| PyOverflowError::raise(ctx, "size is too large")) } ``` -C wrappers apply each function's error contract. Ambiguous numeric sentinels -also require checking whether an exception is pending. Once failure is known, -`assume_set` converts the ambient exception to the marker: +A C wrapper applies the function's complete error contract. Ambiguous numeric +sentinels also require inspecting the error indicator. Once failure is known, +it immediately takes ownership of the raised exception and clears the error +indicator before any unrelated Python operation or release processing. The +private capture happens while the wrapper still controls the exact originating +state, before resuming its context: ```rust +// Within the private C-call wrapper, with its exclusive borrow held: if ptr.is_null() { - // SAFETY: this branch is reached only for a C API whose documented NULL - // result guarantees that it set an exception on `ctx`'s thread state. - Err(unsafe { PyErrRaised::assume_set(ctx) }) + // SAFETY: the audited C API's NULL result guarantees an exception on + // this exact, restored state. Capture transfers the raised reference. + Err(unsafe { take_raised_exception(call_state) }) } else { - // Turn the owned pointer into a Bound value. + // Transfer the owned result into the entry's Bound representation. } ``` -`assume_set` is unsafe because a false marker would let a callback return an -error sentinel without an exception; it therefore debug-checks the state. At -the FFI boundary, `Err` requires a pending exception; otherwise the trampoline -sets `SystemError`. `Ok` requires no pending exception; on mismatch the -trampoline releases the result and returns its error sentinel. These checks -also reject stale saved markers. - +Taking the raised exception is a pointer transfer, not a new allocation or +incref; `_PyErr_GetRaisedException` already provides the basic operation. +The private helper also records the interpreter key. A missing exception +where the C contract requires one becomes `SystemError` through authorized +reporting, never an unchecked null owning handle. Error creation or reporting +that itself fails must preserve an appropriate CPython exception such as +`MemoryError`; it cannot assume exception construction is infallible. + +At the generated boundary, keep a successful result or owned error alive +through conversion and cleanup, then commit the C result. `Ok` requires a +clear error indicator; otherwise release the result through authorized +cleanup and return an appropriate error sentinel. For `Err`, first check its +interpreter key without touching the exception pointer, then restore/steal its +reference into the current state and return the ABI's error sentinel. A +foreign error is queued under its original key and replaced with a local +`SystemError`. This catches even an error obtained indirectly from another +interpreter's callback. + +Restoring or replacing an ambient exception may decref the previous exception +and reenter, so it requires the exclusive-call protocol too. Finish cleanup +before the final restore where possible; any subsequent release processing +must save and restore that incoming exception. Custom exception types stored +as `Py` require checked binding before use. Built-in exception +accessors obtain an appropriately associated handle for the current context. +Native identity, entry, detachment, and interoperation errors never implicitly +consult ambient attachment; their explicit conversion requires an available +mutable context or private authorized boundary reporting. #### Concrete object abstractions @@ -282,9 +782,13 @@ let bytes_uninit = unsafe { }?; ``` -The unsafe form lends `&mut [MaybeUninit]`, which must be fully initialized. -Error and panic paths discard the unpublished object, so they may leave it partially initialized. -Therefore the closure must not panic. +The unsafe form lends `&mut [MaybeUninit]` and requires every byte to be +initialized before returning `Ok(())`. Error and panic paths may leave it +partially initialized: private cleanup discards the unpublished object +without reading those bytes or exposing them to Python. Initialization runs +under the exclusive-call protocol, with no context passed to the closure; +restoration and cleanup must also work when it panics. Even for zero length, +an implementation must not lend mutable access to a shared bytes singleton. The other initial abstractions are deliberately small. `Bound` can add converted values and type objects, while `PyType::new_exception` creates @@ -300,7 +804,7 @@ Conversions use one trait per direction: ```rust pub trait FromPyObject<'a, 'py>: Sized { fn extract( - ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, obj: Borrowed<'a, 'py, PyAny>, ) -> PyResult; } @@ -308,15 +812,58 @@ pub trait FromPyObject<'a, 'py>: Sized { pub trait IntoPyObject<'py> { fn into_pyobject( self, - ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, ) -> PyResult>; } ``` -Both are fallible because they may allocate or invoke Python protocols. Their -shared `'py` lifetime prevents attached results from escaping. `FromPyObject` preserves -the owner's `'a` lifetime, allowing zero-refcount borrowed arguments; retaining one -requires `to_owned(ctx)`. +Both require exclusive context access and are fallible because they may +allocate or invoke Python protocols. Calling a trait method does not itself +suspend the context: its implementation receives an available capability and +only private C wrappers suspend it around actual calls. Their shared invariant +`'py` lifetime prevents scoped results from escaping or being mixed across +contexts. `FromPyObject` preserves the owner's `'a` +lifetime, allowing zero-refcount borrowed arguments; retaining one requires +`to_owned(ctx)`. Implementations returning a Rust reference with lifetime +`'view` into Python storage require both `'a: 'view` and `'py: 'view`; an +owner lifetime alone is insufficient. An implementation accepting `Py` +first performs checked binding and maps mismatch using the supplied context. +The trait does not carry the short borrow lifetime of `ctx`, so it cannot +return a view requiring continuous attachment. Any such future accessor must +borrow the context for the returned view's lifetime and separately prove its +aliasing and concurrency requirements. + +Exact, published `PyBytes` has a simpler contract: its initialized byte +storage is immutable and can be read while detached. Its accessor is a leaf, +and bounds the slice by the object owner and interpreter entry, without tying +it to the short context borrow: + +```rust +impl<'a, 'py> Borrowed<'a, 'py, PyBytes> { + pub fn as_bytes<'view>( + &self, + ctx: &ThreadContext<'py>, + ) -> &'view [u8] + where + 'a: 'view, + 'py: 'view; +} +``` + +The checked marker must establish exact immutable bytes storage, not merely +accept an arbitrary buffer exporter. The owner keeps the allocation live and +the entry lease protects it through detachment and interpreter shutdown; +neither protection can end while the slice is in use. The accessor performs +no incref and introduces no input copy: + +```rust +let input = data.as_bytes(ctx); +let output = ctx.detach(|| compress_without_python(input)) + .map_err(|err| err.raise(ctx))?; +``` + +A slice into writable or externally mutable buffer storage cannot use this +proof. Such exporters require the separate buffer design in the appendix. Generic implementations compose. `Option` maps `None`, borrowed handles can be promoted with an incref, and an existing `Bound` can be returned without @@ -330,7 +877,7 @@ let object: Bound<'py, PyAny> = value.into_pyobject(ctx)?; Generated callbacks accept a convertible value or `PyResult`. Returning `T` means the Rust function body cannot itself report a Python exception, although converting `T` into a Python object may still fail. Returning `PyResult` lets the function use `?` -to propagate `PyErrRaised`; only an `Ok(T)` value is converted. +to propagate `PyErr`; only an `Ok(T)` value is converted. #### Module definition @@ -365,7 +912,11 @@ pub struct ModuleRef<'a, 'py, S> { ``` Only generated `ModuleDef` glue constructs `ModuleRef` and proves the -state type. +state type and interpreter association. It is invariant in `'py` through its +borrowed module handle. Static definitions describe ABI/layout and contain no +interpreter-owned Python objects; each runtime module instance has its own +state, heap types, and custom exceptions. Instances in the same interpreter +also remain distinct: an interpreter key does not identify a module instance. Modules always have a typed, per-module state, even if it is empty: @@ -374,59 +925,116 @@ pub trait ModuleState: Send + Sync + 'static + Sized { type Gc: ModuleStateGc; fn new<'a, 'py>( - ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, module: ModuleRef<'a, 'py, Self>, ) -> PyResult; } -// SAFETY: implementations must report every strong Python edge, clear each -// edge at most once, and remain valid after partial clearing. +// SAFETY: implementations must validate interpreter association, report every +// strong Python edge, and synchronize traversal, mutation, and partial clear. pub unsafe trait ModuleStateGc: 'static { const ENABLED: bool; + fn validate(value: &S, ctx: &mut ThreadContext<'_>) -> PyResult<()>; fn traverse(value: &S, visit: &Visit<'_>) -> TraverseResult; - fn clear(value: &mut S, ctx: &ThreadContext<'_>); + fn clear(value: &S, ctx: &mut ThreadContext<'_>); } #[derive(ModuleStateGc)] struct Base64State { - error: Option>, + error: GcSlot, } impl ModuleState for Base64State { type Gc = Base64StateGc; fn new<'a, 'py>( - _ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, _module: ModuleRef<'a, 'py, Self>, ) -> PyResult { - Ok(Self { error: None }) + Ok(Self { error: GcSlot::empty(ctx) }) } } ``` -`Send + Sync` supports concurrent access in free-threaded builds; stored -Python objects use `Py`. +`Send + Sync` supports concurrent access in free-threaded builds. Traced +Python fields use `GcSlot`, which owns an optional Python reference and +has a private interpreter key fixed at construction. GC clearing permanently +closes a slot to replacement, so reentrant code cannot repopulate an already +cleared edge while later clear callbacks are no-ops: -`#[derive(ModuleStateGc)]` emits the associated policy. A sealed field trait -recurses through `Py`, `Option`, and supported containers; Rust-only -state emits `ENABLED = false` and installs no GC callbacks. Manual policy -implementations are unsafe: they must report every strong edge, clear each at -most once, tolerate partial clear, and never block during traversal. +```rust +impl GcSlot { + pub fn empty(ctx: &ThreadContext<'_>) -> Self; + + pub fn get<'py>( + &self, + ctx: &mut ThreadContext<'py>, + ) -> Result>, InterpreterMismatch>; + + pub fn replace<'py>( + &self, + ctx: &mut ThreadContext<'py>, + value: Option>, + ) -> Result>, GcSlotError>; +} +``` + +`empty` is a metadata-only leaf. `get` and `replace` conservatively require +exclusive context access because their synchronization may need to park the +thread; any suspension occurs before acquiring slot protection. Both check +the slot's key before accessing its object pointer. +`replace` also binds the incoming value to the same scope through its type. +Its native `GcSlotError` distinguishes interpreter mismatch from a cleared +slot; either error leaves the slot unchanged and drops the supplied value by +the ordinary release policy. `get` returns `None` for a cleared slot after +checking identity. On success `replace` returns ownership of the removed +reference, so its decref occurs after releasing internal synchronization. +`get` acquires its returned strong reference while the slot contents are +protected, paying an incref to keep concurrent replacement safe. No API +exposes a mutable reference to the stored handle or lets safe code change a +published slot's interpreter association or reopen a cleared slot. -CPython allocates `StateStorage`, an atomic phase plus `MaybeUninit`, -before Rust constructs `S`. The macro rejects types whose alignment exceeds +`#[derive(ModuleStateGc)]` emits the associated policy. A sealed field trait +recurses through `GcSlot` and explicitly supported containers whose safe +mutations preserve that association. Before publishing state, `validate` +checks every traced slot against the containing module's key, including +empty slots. Bare `Py`, `Option>`, arbitrary `Mutex`/`Arc` wrappers, +and containers exposing unchecked replacement do not automatically qualify +as derived GC fields. Rust-only state emits `ENABLED = false` and installs no +payload GC callbacks. Manual policies are unsafe and must enforce the same +publication and mutation rules, correct edge multiplicity, pointer liveness +during visits, partial-clear validity, and nonblocking traversal. + +`GcSlot` clearing uses shared access and synchronized removal, never a Rust +`&mut S` across a decref. Its private implementation must let traversal visit +a live, correctly accounted reference throughout each `visitproc` call even +when another thread could otherwise mutate or clear the slot. Traversal may +rely on CPython's GIL or stop-the-world exclusion only after auditing every +supported traversal caller and ensuring slot mutations participate in that +exclusion. It must not block or invoke Python to acquire protection, expose +an unsynchronized `&Py`, or hold a lock across decref. Atomic pointer loads +alone do not prove these requirements. Removed references are released only +after the slot is empty and closed and its internal protection has been +exited; clearing an already closed slot is a no-op. Ordinary replacement with +`None` merely empties a slot and does not close it. Reentrant traversal +therefore sees valid shared state. Any supported container needs the same +proof before a sealed implementation is added. + +CPython allocates `StateStorage`, an interpreter key, atomic phase, and +`MaybeUninit`, before Rust constructs `S`. Generated glue initializes the +key from the current context. The macro rejects types whose alignment exceeds the allocator's documented guarantee and checks all size arithmetic. Repeated, reentrant, and concurrent exec calls require these explicit phases: | Operation | Allowed source phase | Published phase and access | | --- | --- | --- | | Claim exec | `Uninitialized` | `Initializing`; any other source raises `SystemError`. | -| Finish `S::new` | `Initializing` | `FailedWithoutValue`, or write `S` and publish `Executing`. | +| Finish `S::new` | `Initializing` | Validate traced associations before publication; on failure drop the unpublished value and publish `FailedWithoutValue`; otherwise write `S` and publish `Executing`. | | Finish exec | `Executing` | `Ready` or `FailedWithValue`; `S` remains live. | -| Read state | `Ready` | Borrow `&S` for the `ModuleRef` lifetime. | +| Read state | `Ready` | Check storage identity; borrow `&S` for the intersection of the module-owner lifetime and guarded entry scope. | | Traverse | `Executing`, `Ready`, `FailedWithValue`, `Clearing`, `Cleared`, `ClearFailed` | Borrow `&S`; never traverse `Dropping` or `Dropped`. | -| Clear | `Ready` or `FailedWithValue` | Enter `Clearing`, lend `&mut S`, then publish `Cleared` or `ClearFailed`; later clears are no-ops. | +| Clear | `Ready` or `FailedWithValue` | Enter `Clearing`, clear slots through `&S`, then publish `Cleared` or `ClearFailed`; later clears are no-ops. | | Free with value | `Executing`, `Ready`, `FailedWithValue`, `Clearing`, `Cleared`, or `ClearFailed` | Enter `Dropping`, drop once, then publish `Dropped`. | | Free without value | `Uninitialized` or `FailedWithoutValue` | Publish `Dropped` without calling `drop_in_place`. | @@ -434,7 +1042,7 @@ The exec hook has the typed signature: ```rust type ModuleExec = for<'a, 'py> fn( - &ThreadContext<'py>, + &mut ThreadContext<'py>, ModuleRef<'a, 'py, S>, &S, ) -> PyResult<()>; @@ -444,17 +1052,27 @@ Non-panicking guards apply failure phases during unwinding. Stores that publish an initialized value or completed transition use release ordering; dependent reads use acquire ordering. -CPython calls clear only after unreachability, and callback borrows cannot -escape, which justifies lending `&mut S`. Derived clear takes each `Py` -before `drop_with(ctx)`, making it idempotent. +Unreachability alone does not justify an exclusive Rust payload borrow: +decref may reenter traversal and free-threaded access needs synchronization. +Derived clear removes edges through their shared `GcSlot` interface before +releasing them in the containing interpreter. Traversal retains its restricted +visitor capability; it cannot acquire a general context, drain a release +queue, or bind arbitrary `Py` handles. Identity validation has already +occurred at publication and mutation, rather than during GC. Free never retries a panicking destructor. Non-panicking guards still publish -`Dropped` and release the allocation. +`Dropped`; Rust drops `S` but CPython retains responsibility for freeing the +module-state allocation. Generated modules declare `Py_mod_gil = Py_MOD_GIL_NOT_USED`, the internal exact-version ABI, the GIL/free-threaded build mode, and -`Py_mod_multiple_interpreters = Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED`. -Stable-ABI and subinterpreter support are out of scope. +`Py_mod_multiple_interpreters = Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`. +These declarations require an audit of native dependencies and mutable +process-global state as well as the Rust wrapper. Process-global Rust caches +may contain only Rust data or opaque, tagged handles checked before use; +interpreter-owned runtime types and exceptions belong in module state. The +module flags advertise support but do not substitute for the identity and +lifetime checks, even when the importer disables extension checks. #### Function definition @@ -467,7 +1085,7 @@ pending a buffer API design: ```rust #[pyfunction(signature = (data, /))] fn standard_b64encode<'a, 'py>( - ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, _state: &Base64State, data: Borrowed<'a, 'py, PyBytes>, ) -> PyResult> { @@ -477,7 +1095,11 @@ fn standard_b64encode<'a, 'py>( The macro identifies context and state by position, then emits compiler-checked uses of their declared types. The state determines `ModuleMethodDef`. -Functions that detach take `&mut ThreadContext`; others take a shared borrow. +Functions that create objects, convert generic values, report Python errors, +or otherwise use exclusive operations take `&mut ThreadContext`. A function +whose body uses only leaf operations may take a shared borrow. Generated +argument and result conversion still use exclusive access before and after +that body's borrow. State and payload references remain shared in either case. `#[pyfunction]` preserves the Rust function body and emits its typed definition, argument specification, and `METH_FASTCALL | METH_KEYWORDS` trampoline. The @@ -490,41 +1112,65 @@ The C input is untyped, so private glue validates it against the selected use the same design with `METH_METHOD`. Panic containment covers entry bookkeeping, parsing, user code, conversion, -and cleanup. A pointer-returning boundary is conceptually: +and cleanup. The private entry guard stays alive during panic payload +handling and error reporting; catching outside `with_current` alone would +release lifetime protection too soon. A pointer-returning boundary is +conceptually: ```rust -// SAFETY: callers must obey `PyCFunctionFastWithKeywords`, provide live +// SAFETY: callers must obey PyCFunctionFastWithKeywords, provide live // callback arguments, and invoke this function with an attached thread state. unsafe extern "C" fn trampoline( /* existing PyCFunctionFastWithKeywords arguments */ ) -> *mut ffi::PyObject { - let call = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // SAFETY: the callback has an attached state and live arguments; the - // closure consumes every attached value before `'py` ends. - unsafe { + let boundary = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: callback inputs satisfy entry's attached-state contract. + let entered = unsafe { with_current(|ctx| { - let result = call_rust_function(ctx); - translate_result_to_raw(ctx, result) + let body = std::panic::catch_unwind( + std::panic::AssertUnwindSafe(|| { + let value = call_rust_function(ctx); + convert_callback_result(ctx, value) + }), + ); + let result = match body { + Ok(result) => result, + Err(payload) => Err(panic_to_owned_error(ctx, payload)), + }; + // The context and entry guard still protect cleanup and the + // final transfer of an owned result/error to the C caller. + finish_callback(ctx, result) }) + }; + match entered { + Ok(ptr) => ptr, + // SAFETY: this helper establishes private reporting authorization + // or fails fatally; it never uses a rejected or stale context. + Err(status) => unsafe { translate_entry_error_to_raw(status) }, } })); - - match call { + match boundary { Ok(ptr) => ptr, - Err(payload) => { - dispose_panic_payload(payload); - // SAFETY: unwinding restored attachment; this fixed-message helper - // does not allocate, invoke user code, or unwind. - unsafe { set_fixed_callback_panic_exception() }; - std::ptr::null_mut() - } + // An entry/restoration/reporting invariant failed. Do not attempt to + // report through a context whose guard may already have been dropped. + Err(_) => std::process::abort(), } } ``` -Because `panic_any` payload destruction may panic, `dispose_panic_payload` -drops it under a second catch; a second panic is leaked and aborts. Reporting -uses a fixed unraisable exception message and aborts if it cannot remain non-panicking. +`panic_to_owned_error` disposes of the payload while holding exclusive context +access and suspending it around arbitrary payload destructor code. Because a +`panic_any` payload destructor may itself panic, disposal has a second catch; +a second panic aborts. The helper then constructs a fixed-message `SystemError` +through the ordinary error path, with the context available again. A fixed +message does not make reporting allocation-free or callback-free: exception +creation can allocate, and replacing an exception can run finalizers. + +Mandatory registration, restoration, and lifecycle guards must not unwind. +A panic already in flight restores attachment before the inner catch receives +it. The outer catch is a last-resort fatal boundary, not the normal path for +reporting author panics. Cleanup drain failures and inability to maintain the +CPython error contract cannot escape the non-unwinding C ABI. Not every callback ABI can report a normal Python exception, so generated glue uses a callback-specific policy: @@ -535,19 +1181,31 @@ uses a callback-specific policy: | Module create | Set a fixed `SystemError` and return `NULL`. | | Module exec | Commit the appropriate failed state phase, set a fixed `SystemError`, and return `-1`. | | Module or instance clear | Finish the lifecycle transition, set `SystemError`, and return failure; CPython reports it as unraisable where required. | -| Traverse | Never leave a Python exception pending. A panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | +| Traverse | Preserve incoming exception state and propagate the visitor's exact nonzero return and any error it sets. A Rust panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | | `tp_dealloc` and module free | Run mandatory non-panicking cleanup guards first, then report the panic as unraisable when an attached state is available. Abort if cleanup itself cannot be completed safely. | Before `drop_in_place`, deallocation commits the destroyed phase and installs guards for the Python allocation and heap-type reference. A panic therefore cannot cause a second Rust drop or skip mandatory cleanup. Module -state uses the same pattern. +state uses the same pattern. Generated lifecycle glue holds exclusive context +access and suspends the record around arbitrary payload/state destructors +and any C cleanup that may reenter. These destructors receive no context, so +they cannot be responsible for calling `ctx.interop` themselves. Restore the +exact state and resume the context before reporting a panic. The record, +release sink, and lifetime protection remain live throughout this cleanup. + +During finalization, the same suspension preserves cleanup-phase restrictions; +it does not authorize unrestricted foreign interoperation. Only transitions +covered by the private cleanup authorization may proceed, with exact-state +restoration and protection through the full synchronous call. This applies +to panic payload destruction as well as class and module destructors. #### Class definition and method definition -A Rust payload type `C` identifies one Python class, its module state, and its -GC policy: +A Rust payload type `C` identifies a class layout and static definition, its +module-state type, and its GC policy. Each module instance creates its own +runtime Python type from that definition: ```rust pub trait PyClass: Send + Sync + 'static + Sized { @@ -557,13 +1215,14 @@ pub trait PyClass: Send + Sync + 'static + Sized { fn definition() -> &'static ClassDef; } -// SAFETY: implementations must report every strong Python edge, clear each -// edge at most once, and synchronize access according to the class lifecycle. +// SAFETY: implementations must validate interpreter association, report every +// strong Python edge, and synchronize traversal, mutation, and partial clear. pub unsafe trait PyClassGc: 'static { const ENABLED: bool; + fn validate(value: &C, ctx: &mut ThreadContext<'_>) -> PyResult<()>; fn traverse(value: &C, visit: &Visit<'_>) -> TraverseResult; - fn clear(value: &mut C, ctx: &ThreadContext<'_>); + fn clear(value: &C, ctx: &mut ThreadContext<'_>); } #[derive(PyClassGc)] @@ -588,18 +1247,24 @@ static ENCODER_CLASS: ClassDef = This prevents safe code from combining an unrelated payload, module state, or definition. `#[derive(PyClassGc)]` uses the same sealed field rules as module -state and disables GC slots when no field can own Python references. Manual -implementations are unsafe because they must report every edge, clear it once, -and obey the class's synchronization rules. +state. `ENABLED` controls payload-edge handling; every generated instance of +a heap type still owns an implicit edge to its actual type, which may in turn +own its defining module. Generated traversal reports that edge even for a +Rust-only payload such as `Encoder`. Manual payload policies are unsafe +because they must validate every edge's association, report it with the +correct multiplicity, clear it safely, and obey the class's synchronization +rules. The common glue handles the implicit type edge separately. `PyType` and `PyInstance` are transparent markers over this identity. The default represents an ordinary untyped Python type object. -`ENCODER_CLASS` creates `Bound>`; module state stores -`Py>`. Upcasts to untyped `PyType` or `PyAny` are free. -Downcasts must validate the class token. Generated methods, getsets, and -constructors all carry `C`; method state is fixed to `C::State`. +`ENCODER_CLASS` creates `Bound>`; a traced module-state field +stores it in `GcSlot>`. Upcasts to untyped `PyType` or `PyAny` +do not touch the refcount or change interpreter association. Downcasts first +establish the correct context and then validate the class token. Generated +methods, getsets, and constructors all carry `C`; method state is fixed to +`C::State`, but its value comes from the actual defining module instance. `ClassDef` computes the payload layout with checked arithmetic and installs lifecycle slots. Types whose alignment exceeds the object allocator's @@ -607,12 +1272,22 @@ documented guarantee are rejected before allocation. The allocation records initialization, clear, and drop phases so callbacks never read uninitialized or destroyed `C`. -`ClassDef::create(ModuleRef)` combines static slots with the -runtime module pointer and returns `Bound>`. It verifies the unique -`C::definition()`; a second definition requires a Rust newtype. The definition -address is also stored as `Py_tp_token`. `new_instance(payload)` validates that -token before allocation or payload access, then returns -`Bound>`. +`ClassDef::create(ctx, ModuleRef)` combines static slots with the +runtime module pointer in the same invariant scope and returns +`Bound>`. It verifies the unique `C::definition()`; a second static +definition requires a Rust newtype. The definition address is also stored as +`Py_tp_token`. It proves layout identity, not interpreter identity or a unique +runtime type: several module instances, including in different interpreters, +can have types with that token. No process-global Python type cache is used. + +`type_object.new_instance(ctx, payload)` validates the type token and every +payload GC slot's interpreter association before publishing an instance, +then returns `Bound>`. Failure drops the unpublished payload +using the ordinary release policy, which is safe even if it contains foreign +handles. Generated methods recover the defining module through the runtime +type's module association (`METH_METHOD` where applicable), validate its state +definition and interpreter key, and cap payload/state borrows by both owner +and entry lifetimes. Rust structs in classes must be `Send + Sync + 'static`. Python methods receive only `&self`, never `&mut self`, because aliases can call the same object concurrently in a @@ -622,25 +1297,25 @@ decision listed in the appendix; an ordinary `Mutex` must not be presented as the general solution until those rules are settled. -Python-owning fields enable generated GC slots: +Python-owning fields add payload edges to the generated GC slots: ```rust #[derive(PyClassGc)] struct Node { - parent: Option>>, + parent: GcSlot>, value: u64, } ``` -With a `PyClass` implementation like `Encoder`'s, `Node` sets -`Py_TPFLAGS_HAVE_GC` and installs `tp_traverse` and `tp_clear`. Lifecycle rules -are: +Generated heap-type instances, including both `Encoder` and `Node`, set +`Py_TPFLAGS_HAVE_GC` and install traversal for the actual heap-type edge; +`Node` additionally traverses and clears its payload edge. Lifecycle rules are: | Operation | Invariant | | --- | --- | -| Allocate | Write `C` into an untracked object, publish ready, then call `PyObject_GC_Track`. | -| Traverse | Borrow `&C`; only call `visitproc`; never invoke Python or block. | -| Clear | After unreachability, lend `&mut C`; take and `drop_with(ctx)` each edge once; later clears are no-ops. | +| Allocate | Validate payload associations against the type/context key; write `C` into an untracked object, publish ready, then call `PyObject_GC_Track`. | +| Traverse | Visit the actual heap type and payload edges through shared access; only call `visitproc`, propagate its exact return, and never invoke Python or block independently of the visitor. | +| Clear | Remove payload edges through synchronized `GcSlot` access using `&C`; release removed references after leaving slot protection; later clears are no-ops. The instance retains its type until deallocation. | | Deallocate | Untrack first. Contain any required clear independently; then commit dropping, drop `C` once, run allocation/type guards, and report the first panic. | Types are immutable, final, and non-instantiable unless they define `tp_new`. @@ -656,9 +1331,11 @@ module state. Rust factories may always use `new_instance`. - `#[new]` creates a `tp_new` trampoline which parses arguments, constructs `C`, and a `ClassNewDef` which moves it into a new Python allocation. -All generated slots retain CPython's existing ABI and enter the common context -and panic boundary. The resulting `Py>` belongs in module state; -`add_type` accepts its safe untyped upcast. +All generated slots retain CPython's existing ABI and panic containment. +Executable callbacks enter the common context machinery, including authorized +cleanup entry during finalization; traversal uses only the restricted visitor +capability. Runtime type references belong in module state using its traced +field policy; `add_type` accepts the safe untyped upcast. #### Argument parsing @@ -668,7 +1345,7 @@ Argument parsing exposes a small PyO3-like signature syntax: ```rust #[pyfunction(signature = (data, value = 1, /))] fn checksum<'a, 'py>( - ctx: &ThreadContext<'py>, + ctx: &mut ThreadContext<'py>, state: &ChecksumState, data: Borrowed<'a, 'py, PyBytes>, value: u32, @@ -694,32 +1371,141 @@ initial grammar grows only as modules require more features. CI should run `cargo fmt` and [Clippy](https://github.com/rust-lang/rust-clippy). -A new `xxtestrustapi` module will be introduced to the normal Python test suite suite to ensure the correctness of the -implementation and check invariants of the Rust code. - -CI will run a mechanical check that every Python-touching safe `c_api` -wrapper must accept `ThreadContext`; an operation without one needs an audited -thread-state-free justification. +A new `xxtestrustapi` module will be introduced to the normal Python test suite +to ensure the correctness of the implementation and check invariants of the +Rust code. The design requires the following focused coverage before enabling +its support declarations: + +- Compile-fail cases for mixing invariant scopes, returning scoped handles + or Python-backed Rust references from an entry, treating a static `Py` + owner as permission for static payload access, and using a context captured + directly or through scoped TLS while that context is exclusively borrowed. + Include attachment-dependent views held across detach, protocol calls, + explicit decref, and release drains. Compile-pass cases must allow opaque + handles and non-`Send` Rust captures/results in same-thread `detach`, and + exact immutable bytes views whose owner and entry outlive detachment. +- Regressions modeled on PyO3 #2141 and #3640: `SendWrapper`, a capture-free + function reading scoped TLS, and dynamic exclusive TLS/`RefCell` borrowing. + Check implicit formatting, comparison, conversion, iteration, and destruction + as well as explicit methods. A handle reachable while detached must remain + opaque without an available context. +- Interpreter A -> Python protocol -> interpreter B -> Rust callback reentry, + including a TLS hook holding A's values. Repeat with a different thread + state in A. Old contexts stay inaccessible; callback scopes use the actual + state, and return/unwind restores the exact outer state. An extensible + conversion trait must receive an available context, not one whose record + was suspended around the whole trait invocation. +- Cooperating foreign wrappers under `interop`, allowed nested detachment, + and native preflight refusal with unchanged registration and attachment. + Subprocess tests for uncooperative public and internal detach/swap calls + must fail before invalidating an available context. Include direct Rust TLS + access from the detached closure, without a generated callback entry. + Forgetting public handles must not affect registration or lease ownership. +- Two simultaneous subinterpreters, including separate-GIL configurations: + distinct module state, runtime classes, and exceptions; checked binding, + conversion, explicit destruction, and traced-field publication/mutation + reject foreign handles before reading their pointers. Repeat with multiple + module instances in one interpreter and with extension import checks off. +- Legitimately shared constants obtained independently in two interpreters: + pointer equality or immortality does not permit relabeling a stored handle. + Interpreter destruction and runtime reinitialization cannot revive old + keys, even if allocator addresses or public interpreter IDs are reused. +- Attached, detached, wrong-interpreter, and unwinding drops: one ownership + transfer and at most one decref per handle, correct interpreter on release, + and preservation of any pending exception. Include TLS/native-thread drops + after finalization and `Py::drop_with` mismatch behavior. An implicit drop + while a shared context is exposed must never run a finalizer synchronously. + Exercise inline sink overflow, nested scopes, and suspended outer sinks; + no node or lookup may retain a pointer to a retired entry. +- Fault-injected queue allocation failure, full ordinary pending-call queues, + and admission/notification races with shutdown: accepted requests drain + before teardown; rejected requests neither dereference nor decref objects. + Include producers paused at each admission boundary and finalizers that + reenter Rust or enqueue further releases, without locks held across Python. + After external admission closes, authorized cleanup drops still drain; + unauthenticated producers cannot acquire cleanup permission. Test cascades + of queued drops from module/type teardown after the first final drain. + Include an active consumer with an empty queue whose C deallocator detaches: + its batch and interpreter must remain protected until draining completes. +- Shutdown racing with an active detached Rust scope: its state remains live + through reattachment; cleanup reentries after normal admission closes work + and cannot request explicit detach. Test audited internal temporary detach + during cleanup as well. Repeat cross-extension reentry with two `cdylib`s + to verify CPython-owned OS-thread registrations and lifetime bookkeeping. +- Panic during detachment, parsing, conversion, and payload destruction: + exact attachment is restored, the entry lease survives panic reporting, + and a second panic or failed restoration cannot cross the C ABI. Include + finalizers triggered by replacing exceptions and allocation failure during + reporting. Owned errors caught and discarded in Rust leave no ambient error; + same-interpreter state transfer works and foreign errors are rejected. +- Fork with queued references and paused detached producers, including + surviving-thread scope restoration and child-side interpreter deletion: + no inherited lock, orphaned in-flight count, or stale scope record prevents + progress or allows access to a removed interpreter. +- Concurrent GC-slot reads, replacement, traversal, and clear: returned owned + references remain live, foreign slots cannot be published, reentrant visits + never overlap an exclusive payload borrow, closed slots reject replacement, + and heap-type/module cycles are collected even for Rust-only payloads. + +Run the relevant runtime cases on GIL and free-threaded builds. Also verify +that shared Rust payload references cannot expose unsynchronized interior +mutation, even when attachment is released and another thread calls Python. + +Measure leaf access, unbound binding, outermost and nested entry, exclusive +call suspension/resumption, GC-slot access, explicit decref, inline scoped +drops, and interpreter-queue overflow against equivalent C operations. +Include contention, tiny frequent callbacks, compression throughput, and +retained-memory measurements. The intended cost distribution is: + +- Attachment and phase checks at scope entry and transition boundaries, + with no repeated TLS validation for consecutive leaf operations. +- Per-OS-thread registration changes around potentially reentrant calls; + no global registry lookup or allocation on the ordinary suspension path. +- Key comparisons at unbound-handle and traced-field publication boundaries, + and finalization admission bookkeeping at protected entry boundaries. +- Inline enqueue for ordinary scoped drops, batched decref, and native queue + synchronization/allocation on overflow or unbound submissions without a + matching sink. Explicit release avoids that queue traffic. +- Refcount traffic for mutable GC-slot reads; no copy for exact immutable + bytes input or fresh allocation merely to capture an existing C exception. + +These are implementation targets, not measured performance guarantees. Tune +batch sizes and sink storage using those measurements before claiming parity +with equivalent C code. + +CI will check that every Python-touching safe `c_api` wrapper accepts a +context or sealed visitor/release capability, and that shared-context +wrappers have an audited leaf contract. Private capability constructors, +transition gates, restoration, and exception paths need separate audits; +a signature check cannot establish those proofs. Operations such as deferred +submission that use neither need an audited thread-state-free justification, +including their allocation, notification, and failure paths. ### Appendix: Open decisions and future work The following items are intentionally kept out of the normative API sections: -- **Subinterpreters.** Future support requires an interpreter-identity model - for contexts, owning handles, module state, and process-global Rust values. +- **Reclamation after interpreter shutdown.** The normative policy safely + abandons references that cannot be submitted before shutdown or on queue + allocation failure. Registered, revocable ownership records and reserved + release storage could strengthen reclamation guarantees, at a cost to + handle construction, memory usage, and synchronization. - **Buffer protocol.** No safe `PyBuffer` API is included until the design distinguishes read-only and writable exporters; specifies contiguity, format, alignment, ownership, and release on every exit path; and prevents Rust slices from racing with Python mutation or escaping across detach and - reentrant calls. Copying from an exact immutable bytes object is the safe - interim input path. + reentrant calls. The exact immutable bytes accessor above provides the + initial zero-copy input path under a narrower ownership contract. - **Interpreter-aware locking.** The design must either provide a lock which cooperates with attachment, the GIL, and free-threaded stop-the-world pauses, or prohibit blocking, detaching, calling Python, and reaching safepoints while an ordinary Rust lock guard is held. Poisoning and traversal behavior also require an explicit policy. -- **Detached destruction.** Consider a deferred decref queue if detached - destruction is needed in practice. +- **Additional GC containers.** Each container must prove interpreter + association at publication and mutation, correct edge accounting, safe + concurrent traversal/clear, and nonblocking visitor access before gaining a + sealed implementation. Guarded borrowed slot reads may avoid `get`'s incref + if their reentrancy and lifetime requirements can be expressed safely. - **Explicit-state C optimization.** Benchmark explicit-`PyThreadState *` variants for TLS-heavy hot paths; this would not change the safe Rust API. - **Rust runtime and panic strategy.** The initial API requires `std` and From 38ca6cd8c931a4665e5edec7edc8218bab0dd5f5 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Sun, 27 Sep 2026 01:32:51 -0700 Subject: [PATCH 03/16] Tweak how thread availability is tracked --- RUST_API.md | 711 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 516 insertions(+), 195 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index da5bbbe19243ec4..88124de01f2df6c 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -2,10 +2,16 @@ ### Overview -This document designs a safe Rust API for in-tree CPython modules, initially -covering the features needed by `zlib` and providing a base for later Rust +This document designs a safe Rust API for in-tree CPython modules, using +`zlib` to drive the initial requirements and providing a base for later Rust work. +`zlib` is the first full migration target. The `_base64` and `Encoder` +examples below are used to illustrate individual API features. Preserving +either module's buffer-accepting interface requires the buffer work in the +appendix. Stateful `zlib` additionally requires the locking and output-buffer +designs discussed there. + ### Context Rust can call CPython's C API efficiently, but raw FFI does not encode Rust's @@ -24,7 +30,7 @@ In priority order, the API should: 5. use modern APIs such as `PyModExport`. Every use of `unsafe` requires a `// SAFETY:` comment stating its proof -obligation. No panic may escape a non-unwinding C ABI. +obligation. No panic may escape a non-unwinding C ABI, as this is undefined behavior in Rust. ### Non-goals @@ -35,7 +41,7 @@ them. ### Specific guidelines - Encode CPython contracts in the type system where practical. -- Use macros sparingly to control compile time. +- Control compile times - Preserve Rust's initialization, aliasing, and concurrency invariants. - Support both GIL and free-threaded builds. @@ -58,7 +64,7 @@ them. #### Bindings and the FFI boundary -`cpython-sys` generates raw bindings at build time from `Python.h`, internal +The `cpython-sys` crate generates raw bindings at build time from `Python.h`, internal headers, and the active `pyconfig.h`. This captures platform, target, debug, and free-threading-dependent layout information. Bindgen allowlists public and private `Py*` and `_Py*` names. Some C constructs are not replicatable via C bindings such as macros. @@ -79,71 +85,112 @@ access is reserved for audited leaf operations; operations that can run callbacks or change attachment require exclusive access. The scope protocol below maintains attachment without a TLS check at every leaf operation. An operation without a capability needs an audited proof that its entire -call path is thread-state-free. Ordinary `Drop` only submits releases; it -never obtains a capability to decref synchronously. Existing C APIs may -still obtain the current state internally. +call path is thread-state-free. Ordinary `Drop` only submits releases or +applies the statically immortal singleton exception below; it never obtains +a capability to decref synchronously. Existing C APIs may still obtain the +current state internally. #### Interpreter identity and lifetime -Each interpreter lifetime has a private `InterpreterKey`, represented by a -nonzero 64-bit generation number. CPython allocates it from a process-wide -sequence that survives `Py_FinalizeEx` and reinitialization; exhaustion fails -interpreter creation rather than reusing a key. A main interpreter created -after reinitialization receives a new key too. Existing interpreter IDs alone -do not satisfy this contract: `Python/pystate.c` currently resets `next_id` -when enabling the interpreter registry. - -The key records the interpreter under whose API contract a reference was -acquired and is usable, not which interpreter allocated its storage. CPython -may supply process-global constants or static types to several interpreters. -An accessor called in interpreter B may return a B-associated handle to such -an object even if its storage originated in the main interpreter. An existing -A-associated handle cannot be relabeled B merely because the pointer is -immortal or is equal to another pointer. Only an audited CPython contract can -authorize sharing; ordinary cross-interpreter transfer reconstructs a new -object through a separately audited conversion. - -There is no generic object-to-interpreter lookup. `PyObject` headers do not -carry this association, and the free-threaded `ob_tid` describes refcount -thread ownership, not interpreter identity. Generated glue obtains the key -from the attached thread state's interpreter and preserves it through handle -conversions. Reading the current interpreter and its cached key requires no -allocation, registry search, or lock. No `PyObject` header change is required. - -Identity does not prove liveness. Each active outermost Rust entry holds an -interpreter-finalization lease, including while temporarily detached. Nested -entries for the same protected thread state reuse that protection. The lease -is held by private entry glue, cannot escape to user code, and is released on -normal return and unwind after all scoped values have been consumed, queued -releases have been drained or transferred, and panic reporting has finished. Safe -APIs must not initiate or synchronously wait for destruction of that -interpreter while holding its lease. A stored `Py` does not hold a lease: -otherwise module state could prevent the shutdown needed to destroy itself. - -The lease follows the lifetime protection of -[PEP 788 interpreter guards](https://peps.python.org/pep-0788/), using a new -private stack-based entry API to avoid allocating a public guard on each -callback. Acquisition must synchronize with finalization's admission barrier; -checking a finalizing flag before incrementing a counter is insufficient. -Normal entry fails before exposing Rust references if admission has closed. -Generated callbacks that CPython legitimately invokes during finalization -instead receive private cleanup authorization from the finalizing thread. -This also covers synchronous reentrant callbacks caused by those finalizers. -Their contexts are limited to that callback's lifetime and cannot request -explicit detachment or unrestricted foreign-library interoperation. Audited -synchronous C calls may temporarily detach internally only if the cleanup -authorization protects their state through restoration and finalization -cannot advance until the call returns. -Neither user code nor an arbitrary foreign thread can manufacture cleanup -authorization. Traversal has its separate restricted visitor capability and -cannot acquire a general context merely to bypass its restrictions. - -The initial API creates contexts only at generated C entries. A future native -thread entry API must select an explicit interpreter key, acquire lifetime -protection before attachment, and report shutdown failure without a Python -exception when detached. It must not use `PyGILState_Ensure` to implicitly -select the main interpreter. Raw C callers remain responsible for presenting -valid callback inputs and obeying CPython's interpreter contracts. +Each interpreter lifetime has a private `InterpreterKey`: a nonzero 64-bit +generation number from a process-wide counter that must survive +`Py_FinalizeEx` and the runtime reset in `_PyRuntimeState_Init`. Every +interpreter, including a main interpreter created after reinitialization, +receives a fresh key; exhaustion fails interpreter creation rather than +reusing one. Public interpreter IDs cannot serve this role: +`_PyInterpreterState_Enable` resets `next_id`, the main interpreter's ID must +be zero, and `PyInterpreterView_FromMain` hardcodes that value. For the same +reason, an interpreter view, which resolves by public ID, cannot prove +generation identity after reinitialization. The key adds one field per +interpreter and no extra word to each Rust handle. + +A key names the interpreter under whose API contract a reference was +acquired and may be used, not the interpreter that allocated its storage. An +accessor called in interpreter B may return a B-associated handle to a +process-global constant or static type that originated in the main +interpreter. An existing A-associated handle is never relabeled B merely +because the object is immortal or the pointers compare equal. Only an audited +CPython contract can authorize sharing; ordinary cross-interpreter transfer +reconstructs the object through a separately audited conversion. + +Objects carry no interpreter association, so there is no generic +object-to-interpreter lookup and no `PyObject` header change is required. The +free-threaded `ob_tid` records refcount thread ownership, not interpreter +identity. Generated glue reads the key from the attached thread state's +interpreter, which needs no allocation, registry search, or lock, and +preserves it through handle conversions. + +A key identifies an interpreter but does not keep it alive. That is the job +of an interpreter-finalization lease, held by each Rust entry for its whole +duration, including while detached. Finalization waits until no lease +remains. Nested entries acquire their own leases rather than searching for +an enclosing one. While an enclosing entry holds a lease on the same +interpreter, finalization cannot close admission, so the nested acquisition +succeeds. Private entry glue owns the lease and releases it +last, on return or unwind, after every scoped value has been consumed, queued +releases have been drained or transferred, and panic reporting has finished. +Safe APIs must not start, or block waiting for, finalization of an +interpreter whose lease they hold, since that finalization would wait on +their own lease. A stored `Py` never holds a lease; otherwise a `Py` in +module state would block the shutdown that destroys that module state. + +The lease must span the whole entry, not just explicit `detach` and `interop` +calls. Attachment already protects the entry thread itself, because +finalization hangs a paused thread rather than resuming it. It does not +protect borrows the entry has lent out: `&S`, `&C`, and `&[u8]` point to +`Sync` data, so safe code can pass them to scoped worker threads. + +1. An entry on a daemon thread lends `&S` to a scoped worker. +2. The entry thread calls Python, which detaches internally, for example in + `socket.recv`. +3. Finalization hangs the daemon thread on reattach and removes its thread + state. +4. The worker is still reading `S`. + +On free-threaded builds, module objects and module-level functions use +deferred reference counting, and the collector finds their stack references +only through registered thread states. After step 3 those references are no +longer roots, so the module and `S` can be freed while the worker reads `S`. +Leasing only explicit detaches would not help, because step 2 is not one, and +a bare Rust reference has no destructor with which to release a lease of its +own. The whole-entry lease also keeps the entry's release sink valid until it +is drained. + +The cost is that a Rust entry blocked indefinitely in a Python callback also +blocks interpreter shutdown indefinitely; the appendix discusses the +migration impact. This is deliberate and must not be weakened by expiring the +entry's references or abandoning its accepted releases. + +[PEP 788 interpreter guards](https://peps.python.org/pep-0788/) are the basis +for the lease mechanism. A new private +stack-based entry API uses the existing `try_acquire_interp_guard` CAS, +avoiding a guard allocation per callback. A decrement-and-unpark operation +factored out of `PyInterpreterGuard_Close` lets a stack guard release its +lease without freeing its storage. Acquisition must synchronize with +finalization's admission barrier; checking a finalizing flag before +incrementing a counter is insufficient. Once admission closes, normal entry +fails before exposing any Rust reference. + +Callbacks that CPython legitimately invokes during finalization, and +synchronous reentrant callbacks caused by them, instead receive private +cleanup authorization from the finalizing thread. Their contexts last only +for that callback and cannot request explicit detachment or unrestricted +foreign-library interoperation. An audited synchronous C call may detach +internally only if the authorization protects its state through restoration +and finalization cannot advance until the call returns. Neither user code nor +an arbitrary foreign thread can manufacture cleanup authorization, and +traversal cannot turn its restricted visitor capability into a general +context. + +The initial API creates contexts only at generated C entries. A future safe +native-thread entry should reuse `PyThreadState_Ensure` and +`PyThreadState_EnsureFromView`, adding generation validation and the scope +protocol below. It must take an explicit interpreter key rather than trusting +view lookup, acquire matching lifetime protection before attaching, and +report shutdown failure without a Python exception while detached. +`PyGILState_Ensure` must not implicitly select the main interpreter. Raw C +callers remain responsible for valid inputs and CPython's interpreter +contracts. #### Managing the thread context @@ -157,7 +204,7 @@ state and other thread-local CPython state belong to the exact thread state. pub struct ThreadContext<'py> { ptr: NonNull, interpreter: InterpreterKey, - scope: NonNull, // private, stable storage in the entry guard + entry: NonNull, // private, stable storage in the entry guard #[allow(clippy::type_complexity)] _marker: PhantomData<(fn(&'py ()) -> &'py (), *mut ())>, } @@ -202,65 +249,79 @@ without borrowing this context. Checking only when a generated callback is entered is also insufficient: a detached Rust closure can call scoped TLS without crossing a generated C entry. -CPython maintains a stack of Rust scope records per OS thread, shared across -all extension libraries and all interpreters on that thread. A record is -**available** while its context can be used, or **suspended** while a private -wrapper holds the exclusive borrow and exposes no access to that context. -At most one record is available on an OS thread. Nested entries are admitted -only while all enclosing records are suspended; they create a fresh scope -for the state actually attached at that entry. Scope depth on an individual -`PyThreadState` is insufficient when C code switches interpreters. +CPython keeps a private thread-local flag on each OS thread recording whether +a Rust context is **available** there, shared across all extension libraries +and all interpreters on that thread. A context is **suspended** while a +private wrapper holds its exclusive borrow and exposes no access to it. At +most one context is available on an OS thread, so one flag is enough, and +CPython keeps no stack of scope records. Nesting follows the machine stack +instead: entry and restoration guards are lexically scoped and cannot be +forgotten, and nested entries are admitted only while the flag is clear, so +each transition below knows the flag's previous value. Nested entries create a +fresh scope for the state actually attached at that entry. + +The flag lives in CPython rather than in a Rust `thread_local!` because each +extension links its own copy of the Rust support crate. Only the owning thread +reads or writes it, since attach, detach, and swap act on the calling thread, +so it needs no atomic access. It is per OS thread rather than per +`PyThreadState`, so the gates can consult it whichever state, if any, is +attached. + +Each `PyThreadState` also gets an atomic Rust protection count. An entry +increments the count of the state attached at entry and decrements it only at +exit, so a suspended entry still protects its state. Release consumers do the +same while they hold a batch. The count is atomic because deletion can run on +another OS thread. The required private operations have these contracts; their names and C layout are implementation details: -1. **Enter:** verify the actual attached state and execution phase, acquire - or reuse interpreter lifetime protection, and register an available record - in stable, private stack storage. Entry cannot publish a context before - all these steps succeed. A per-interpreter lease does not by itself keep - an individual thread state alive: a registered entry also protects its - exact state from deletion until restoration and exit. -2. **Suspend:** while holding `&mut ThreadContext`, mark its record suspended - before invoking a potentially reentrant operation. Native preflight checks - leave attachment and registration unchanged on refusal and do not create - a Python exception. No arbitrary callback occurs between preflight and - committing suspension. The private restoration guard retains the exact - original state, record, and lifetime protection. - Suspension removes access to the Rust capability; it does not broaden - the C operations permitted by the execution phase. Cleanup authorization - and its transition restrictions remain in force. +1. **Enter:** require the flag to be clear. Verify the actual attached state + and execution phase, acquire interpreter lifetime protection, and increment + the attached state's protection count. Only then set the flag and publish + the context; entry cannot publish a context before all these steps succeed. + A per-interpreter lease does not by itself keep an individual thread state + alive; the protection count does, until restoration and exit. +2. **Suspend:** while holding `&mut ThreadContext`, clear the flag before + invoking a potentially reentrant operation. Native preflight checks leave + attachment and the flag unchanged on refusal and do not create a Python + exception. No arbitrary callback occurs between preflight and committing + suspension. The private restoration guard retains the exact original state + and lifetime protection. Suspension removes access to the Rust capability; + it does not broaden the C operations permitted by the execution phase. + Cleanup authorization and its transition restrictions remain in force. 3. **Transition:** CPython's attach, detach, swap, and thread-state deletion - paths reject any transition that invalidates an available record. They - consult the OS-thread registration even when there is currently no - attached state, and cover both public and internal transition paths. - Deletion additionally respects every live thread-state protection, - including suspended records and active consumers, even when attempted - from another OS thread. The deleting thread's own scope stack is not - sufficient. Destruction cannot synchronously wait for a protected entry - on its own call stack to finish. -4. **Resume:** require nested records to have exited and the exact original - state to be attached before making the outer record available again. - Restore it on normal return and Rust unwind according to the operation's - audited contract. If a foreign call violates its restoration contract, - fail without exposing a stale context; do not silently attach over an - unknown state. Failure to restore safely is fatal. -5. **Exit:** after author code and scoped handles are finished, mark the - record unavailable and service or transfer its releases while retaining - the sink, ancestry, and lifetime protection for reentrant cleanup. Remove - the record only after its sink is empty. Cleanup and panic reporting - precede release of lifetime and thread-state protection. - -Registration and restoration guards are private and cannot be returned or -forgotten by safe author code. Forgetting a public handle may leak a reference -but cannot remove attachment protection or strand a suspended registration. -Stable record addresses are established before registration, without requiring -an allocation for each entry. The entry guard, rather than the public context, -owns the record and its associated release sink. + paths reject any transition while the flag is set. They consult the flag + even when there is currently no attached state, and cover both public and + internal transition paths. Deletion additionally rejects a state whose + protection count is nonzero, even when attempted from another OS thread; + this covers suspended entries and active consumers, which the deleting + thread's own flag cannot see. Destruction cannot synchronously wait for a + protected entry on its own call stack to finish. +4. **Resume:** nested entries have already exited, because they run inside the + suspended call. Require the flag to be clear and the exact original state + to be attached, then set the flag again. Restore the original state on + normal return and Rust unwind according to the operation's audited + contract. If a foreign call violates its restoration contract, fail without + exposing a stale context; do not silently attach over an unknown state. + Failure to restore safely is fatal. +5. **Exit:** after author code and scoped handles are finished, clear the + flag, then service or transfer the entry's releases while retaining the + sink and lifetime protection for reentrant cleanup. Retire the sink only + after it is empty. Cleanup and panic reporting precede decrementing the + state's protection count and releasing the lease. + +Entry and restoration guards are private and cannot be returned or forgotten +by safe author code; the single flag depends on this. Forgetting a public +handle may leak a reference but cannot clear the flag, remove attachment +protection, or strand a suspended context. The entry guard, rather than the +public context, owns the release sink and the entry's private state, at a +stable address established without an allocation for each entry. A controlled Rust wrapper checks detachment permission before calling `PyEval_SaveThread`, so that C API needs no new recoverable failure result -for this path. Legacy C transitions that bypass the protocol while an -available record exists must fail-stop before changing attachment; silently +for this path. Legacy C transitions that bypass the protocol while the +flag is set must fail-stop before changing attachment; silently continuing would invalidate safe Rust's proof, and waiting on the same thread would deadlock. A new private checked transition API may return a native status instead. Scope-entry protocol violations likewise fail before exposing @@ -275,12 +336,27 @@ access during that interval. Otherwise it must use suspension. This audit includes free-threaded safepoints and object critical sections, not only `Py_BEGIN_ALLOW_THREADS`. +Implement that exception with a private balanced-parking transition or +private transition reason. The current parking lot calls public +`PyEval_ReleaseThread` and `PyEval_AcquireThread`, so gating public functions +alone does not distinguish explicit detachment from internal parking. The +exemption must remain confined to audited paths that restore the same state +before any callback or Rust access; an arbitrary C caller cannot request it. +Lock paths that handle signals by calling `Py_MakePendingCalls` require the +exclusive-call protocol instead. No exemption permits deletion of protected +states or bypasses interpreter lifetime protection. + +Placed beside CPython's existing thread-local current-state pointer, the flag +makes the common gate one load and branch; deletion adds a load of the state's +count. Transition hooks also affect non-Rust code. Benchmark them on ordinary +C and Python workloads rather than assuming zero overhead. + Entry uses a higher-ranked closure so callers cannot choose `'py = 'static` or return a scoped value. Its conceptual private signature is: ```rust // SAFETY: only generated C callbacks with an attached state and valid inputs -// may enter; the implementation performs registration and lifetime admission. +// may enter; the implementation performs scope entry and lifetime admission. unsafe fn with_current( f: impl for<'py> FnOnce(&mut ThreadContext<'py>) -> R, ) -> Result; @@ -309,6 +385,11 @@ impl<'py> ThreadContext<'py> { f: impl FnOnce() -> R, ) -> Result; + pub fn detach_if_possible( + &mut self, + f: impl FnOnce() -> R, + ) -> R; + pub fn interop( &mut self, f: impl FnOnce() -> R, @@ -318,9 +399,9 @@ impl<'py> ThreadContext<'py> { } ``` -`detach` checks the execution phase, suspends the record, calls +`detach` checks the execution phase, suspends the context, calls `PyEval_SaveThread` or its private equivalent, executes `f` on the same OS -thread, and restores the saved state before resuming the record. Its private +thread, and restores the saved state before resuming the context. Its private guard does the same restoration if `f` panics. The interpreter lease and thread-state protection span the entire detached interval. A refusal returns a native `DetachError` before detaching; a caller can subsequently translate @@ -328,7 +409,25 @@ it through an available context. Cleanup entries refuse explicit detachment. There is no outermost-entry permit: a normal nested callback may detach when all enclosing contexts are suspended and it has lifetime protection. -`interop` suspends the record without initially detaching. Its closure can +`detach_if_possible` makes the fallback explicit: detach when the execution +phase permits, otherwise run `f` while remaining attached. It invokes `f` +exactly once, on the same OS thread, and returns its result unchanged. Only a +legitimate phase restriction selects the attached path; a broken attachment +or scope invariant remains fatal. The exclusive borrow, suspension +around the closure, lifetime protection, and restoration on unwind apply to +both paths. An attached fallback does not grant cleanup code permission for +otherwise forbidden foreign transitions, and does not suppress closure +errors or panics. + +Callers of the fallback operation must tolerate execution while attached. +Straightforward compression or encoding is a suitable use after auditing +its dependencies. A closure that waits for another thread needing Python +progress should use strict `detach` and handle refusal before waiting. +Attached waits can deadlock through the GIL or free-threaded stop-the-world +coordination. The labels "computation" and "waiting" are guidance, not a +type-level effect system; hidden waits and foreign callbacks still matter. + +`interop` suspends the context without initially detaching. Its closure can use a foreign safe wrapper that manages its own attachment, including switching interpreters. That wrapper must restore the incoming state before returning or unwinding; the boundary verifies exact restoration before @@ -338,8 +437,8 @@ This boundary protects this API's contexts; it does not repair a foreign wrapper's own unsound attachment tokens or reference lifetimes. Foreign wrappers remain responsible for their capabilities and C API contracts. -Neither closure receives the borrowed context. Neither needs `Send` bounds -on its captures or result: both execute on the same thread, and the proof is +None of these closures receives the borrowed context or needs `Send` bounds +on its captures or result: they execute on the same thread, and the proof is exclusive access to the capability, not an assertion about captured values. Opaque scoped handles and independently safe Rust data may be moved into or returned from the closure. Accessing Python through a handle still requires @@ -422,7 +521,7 @@ impl Py { Scoped operations accept the same invariant `'py` on all handles and the context, avoiding repeated dynamic identity checks. Leaf access also needs -no repeated attachment check: entry registration and transition enforcement +no repeated attachment check: the scope protocol and transition enforcement maintain that invariant until the next exclusive operation. To propagate a native mismatch through a Python callback, explicitly raise an exception on the current context; no error conversion implicitly consults ambient attachment. @@ -483,9 +582,18 @@ A direct decref could invoke a finalizer which reenters Python or detaches while a shared context remains reachable through scoped TLS. Actual-attachment checks in `Drop` would not establish the exclusive borrow needed for that execution. `Bound::drop` instead appends to its entry's release sink; `Py::drop` -submits by key, with an optional matching live sink fast path. Both transfer +submits to its interpreter's queue by key. Both transfer ownership without reading the object's header or invoking Python. +A sealed marker that guarantees an immortal singleton, such as a dedicated +`None` or boolean marker, may omit release and queue submission entirely. +This is a static guarantee that release is a no-op, not a detached read of +the object's refcount. A general integer marker does not establish it merely +because some small integers are immortal. Upcasting may discard the static +knowledge and use the ordinary queue path; no extra immortality bit needs +to survive in the handle. Interpreter association and binding checks remain +unchanged, including for old singleton handles after reinitialization. + Explicit release and queue draining preserve the incoming Python exception. Generated Rust deallocators contain panics and preserve exception state while reporting an unraisable error. `Drop` must not unwind, including when Rust is @@ -520,12 +628,21 @@ under the policy below. Acceptance includes insertion into an inline sink; later allocation failure cannot revoke it. No mutable borrow of sink storage or queue lock spans a callback. -The sink remains valid through suspended contexts and Rust unwinding. -`Py::drop` may use a CPython-owned OS-thread lookup of a live sink with the -same generation key, including a suspended outer entry's sink, without -inspecting Python memory. Otherwise it submits to the interpreter queue. -This optimization carries the same admission and ownership guarantees as -ordinary submission; a stale sink cannot survive entry exit. Entry exit +**Sink aliasing invariant:** no exclusive Rust reference to sink storage may +remain live across any operation that can drop a handle using that sink. +Pushes use audited interior mutability. Draining extracts a batch using raw +access or a completed short borrow before executing decrefs; the guard must +not retain `&mut` access to the sink across reentry, even if queue locking +would otherwise appear to serialize writes. Nested and unwinding drops must +be tested against this invariant. + +The sink remains valid through suspended contexts and Rust unwinding. Only +`Bound` handles reach it, through their direct pointer. There is no +thread-wide sink lookup: `Py::drop` always submits to the interpreter queue, +even inside an entry for the same interpreter. That costs a queue submission +instead of an inline push, which should be uncommon because stored handles +are mostly dropped at teardown; `drop_with` releases directly when prompt +release matters. A stale sink cannot survive entry exit. Entry exit drains or transfers every buffered reference before retiring the sink. If transfer fails, it drains the retained entries under private release authorization and lifetime protection; it cannot abandon an accepted batch. @@ -580,8 +697,22 @@ not an indefinitely retained Python handle. Notification must remain reliable when the ordinary pending-call queue is full: an accepted release cannot disappear merely because scheduling a wakeup failed. +Use a dedicated release-work notification, such as an eval-breaker bit, +instead of requiring a slot in the bounded pending-call array. The protocol +must also wake a finalizing thread waiting on guards or producers when new +release work needs servicing or the last in-flight producer finishes, and +preserve the existing last-guard-release wakeup. An eval-breaker bit alone +cannot wake an indefinitely parked native waiter. Notification paths retain +endpoint protection until they finish accessing interpreter or thread-state +storage; producer completion and its wakeup must also be coordinated with +endpoint retirement. The wait predicate and wakeup protocol must prevent +lost wakeups when either new work or completion races the finalizing thread's +transition into its native wait. In particular, draining a producer's node +does not imply that its notification and submission bookkeeping have finished. + Before removing a normal batch, the consumer acquires one finalization lease -for that batch or reuses an enclosing entry's protection. It retains this +for that batch and increments its attached state's protection count; +`ctx.drain_releases()` uses its entry's protection instead. It retains this protection through decrefs, reentrant callbacks, and batch cleanup, including any temporary detach inside a C deallocator. Shutdown accounts for these active consumers as well as producers: an empty queue does not prove that a @@ -615,19 +746,26 @@ should expose an explicit close operation with a context. Shutdown uses the following admission protocol: 1. While open, the queue accepts references and can be drained normally. -2. Finalization closes admission of new external Rust-entry leases and lets - existing entries and admitted normal consumers, including their - detach/reattach operations and nested callbacks, finish while the - interpreter is intact. It continues servicing releases under cleanup - authorization, releasing its GIL and any locks needed by those entries - while waiting. New normal consumer leases use the same closed gate. -3. Queue admission closes atomically against producers. Already admitted - submissions finish and all accepted references are drained while the - interpreter can still execute deallocators. Waiting and - decrefing occur without registry/queue locks and without keeping the world - stopped across work that may need another thread. This must integrate - with `Python/pylifecycle.c`'s pre-finalization loop, not be inserted after - that loop returns holding its pending-call lock with the world stopped. +2. The pre-finalization loop lets protected entries and normal consumers, + including detach/reattach and nested callbacks, finish while the + interpreter is intact. It continues servicing releases while waiting, + releasing its GIL and any locks needed by those entries. Entry-local sinks + and removed batches remain covered by their leases until drained or + transferred; an empty interpreter queue does not account for them alone. +3. Coordinate closing queue admission against detached producers with the + existing finalization-guard barrier. The loop's final completion check + includes queued releases and in-flight submissions, in addition to the + existing thread, pending-call, and guard conditions. A submission racing + that check either remains accounted for and causes another drain cycle, + or is rejected by closed admission. One implementation closes admission + in the same synchronized final check as guard admission; another may + close the queue earlier and finish all admitted submissions before that + check. No check-then-enqueue gap is permitted. Waiting and decrefing occur + after restarting the world and releasing incompatible locks. Integrate + this with `make_pre_finalization_calls`, rather than adding a drain after + it returns with the world stopped and its pending-call lock held. Once + guard admission closes, new normal entries and consumers cannot enter; + subsequent drains require cleanup authorization. 4. Authorized finalization callbacks still enqueue their ordinary drops into a cleanup sink. After external admission closes, CPython accepts this sink's releases only under the finalizing thread's private cleanup @@ -665,15 +803,20 @@ finalizer timing even on attached threads, and batching may retain memory longer. Benchmark the inline path, overflow, and unbound `Py` submissions; there is no claim that queuing every implicit drop is free. -On platforms supporting `fork`, the registry, sinks, scope records, and -lease counts must participate in CPython's before/after-fork protocol. -Detached producers must be quiesced or synchronized by that protocol; -stopping attached Python threads alone is insufficient. The child must -reinitialize native synchronization, preserve accepted releases for surviving -interpreters, remove counts and records from vanished threads, and retire -endpoints for interpreters removed after fork. Records and restoration guards -on the surviving thread remain valid. Fork preparation cannot wait while -holding a lock that a paused producer needs. The queue is not ready for +On platforms supporting `fork`, the registry, sinks, availability flag, +thread-state protection counts, and lease counts must participate in CPython's +before/after-fork protocol. Detached producers must be quiesced or +synchronized by that protocol; stopping attached Python threads alone is +insufficient. The child must reinitialize native synchronization, preserve +accepted releases for surviving interpreters, remove the leases held by +vanished threads, clear their states' protection counts before deleting those +states, and retire endpoints for interpreters removed after fork. The +surviving thread's leases can be recovered from its own states' protection +counts, because each protected entry or consumer holds one lease on its +state's interpreter; cleanup-authorized entries hold none, so fork under +cleanup authorization must be refused or accounted for separately. The +surviving thread's flag and guards remain valid. Fork preparation cannot wait +while holding a lock that a paused producer needs. The queue is not ready for general use until these child and parent paths are defined and tested. @@ -789,6 +932,10 @@ without reading those bytes or exposing them to Python. Initialization runs under the exclusive-call protocol, with no context passed to the closure; restoration and cleanup must also work when it panics. Even for zero length, an implementation must not lend mutable access to a shared bytes singleton. +The closure can still call a foreign Python wrapper or run arbitrary +destructors. Suspension permits that reentry while making the outer context +inaccessible; exclusive ownership of the unpublished bytes separately +protects the mutable slice. The other initial abstractions are deliberately small. `Bound` can add converted values and type objects, while `PyType::new_exception` creates @@ -858,12 +1005,13 @@ no incref and introduces no input copy: ```rust let input = data.as_bytes(ctx); -let output = ctx.detach(|| compress_without_python(input)) - .map_err(|err| err.raise(ctx))?; +let output = ctx.detach_if_possible(|| compress_without_python(input)); ``` A slice into writable or externally mutable buffer storage cannot use this proof. Such exporters require the separate buffer design in the appendix. +This example's CPU work must also be valid while attached; calls requiring +actual detachment use strict `detach` and handle its native refusal. Generic implementations compose. `Option` maps `None`, borrowed handles can be promoted with an incref, and an existing `Bound` can be returned without @@ -976,7 +1124,12 @@ impl GcSlot { &self, ctx: &mut ThreadContext<'py>, value: Option>, - ) -> Result>, GcSlotError>; + ) -> Result>, GcSlotReplaceError<'py, T>>; +} + +pub struct GcSlotReplaceError<'py, T> { + pub kind: GcSlotError, + pub value: Option>, } ``` @@ -986,8 +1139,10 @@ thread; any suspension occurs before acquiring slot protection. Both check the slot's key before accessing its object pointer. `replace` also binds the incoming value to the same scope through its type. Its native `GcSlotError` distinguishes interpreter mismatch from a cleared -slot; either error leaves the slot unchanged and drops the supplied value by -the ordinary release policy. `get` returns `None` for a cleared slot after +slot. Either error leaves the slot unchanged and returns the supplied value +inside `GcSlotReplaceError`, preserving ownership without allocation, incref, +or enqueue. The caller can recover it, or drop the error to use the ordinary +release policy. `get` returns `None` for a cleared slot after checking identity. On success `replace` returns ownership of the removed reference, so its decref occurs after releasing internal synchronization. `get` acquires its returned strong reference while the slot contents are @@ -1009,15 +1164,37 @@ during visits, partial-clear validity, and nonblocking traversal. `GcSlot` clearing uses shared access and synchronized removal, never a Rust `&mut S` across a decref. Its private implementation must let traversal visit a live, correctly accounted reference throughout each `visitproc` call even -when another thread could otherwise mutate or clear the slot. Traversal may -rely on CPython's GIL or stop-the-world exclusion only after auditing every -supported traversal caller and ensuring slot mutations participate in that -exclusion. It must not block or invoke Python to acquire protection, expose -an unsynchronized `&Py`, or hold a lock across decref. Atomic pointer loads -alone do not prove these requirements. Removed references are released only -after the slot is empty and closed and its internal protection has been -exited; clearing an already closed slot is a no-op. Ordinary replacement with -`None` merely empties a slot and does not close it. Reentrant traversal +when another thread could otherwise mutate or clear the slot. The initial +implementation uses the following exclusion contract: + +- Published slot access and mutation require attachment. Waiters park + outside slot protection; a holder never parks, reaches a safepoint, runs + callbacks, or releases attachment while protected. This includes failure + and cleanup paths. Slot protection cannot be borrowed by author code. +- Normal GC traversal, `gc.get_referents`, and `gc.get_referrers` have GIL or + stop-the-world exclusion. Stopping attached mutators therefore cannot + leave a partially modified slot. `tp_clear` runs after the free-threaded + collector restarts the world and uses the synchronized mutation path. +- Debug `PyObject_GC_Track` can invoke traversal without stopping the world. + Generated factories cover that case by fully initializing and retaining + exclusive access to the unpublished object until tracking returns. + +Under this full exclusion and publication proof, traversal can load the +slot pointer without taking its lock. Atomic loads alone would not establish +the same reference-lifetime guarantee. Traversal must not block, run Python, +expose an unsynchronized `&Py`, or retain an incompatible exclusive Rust +borrow during a visit. Type/module lookups in traversal use the appropriate +`_DuringGC` accessors, which do not create owned references or Python errors. +Their native failure results must still be handled. + +External C callers invoking `tp_traverse` directly must provide equivalent +exclusion and valid, initialized inputs; arbitrary concurrent traversal of +mutable payloads is outside this unsafe FFI contract. New in-tree traversal +callers must be audited against the same two cases. References removed by clear +are released only after the slot is empty and closed and its internal +protection has been exited; clearing an already closed slot is a no-op. +Ordinary replacement with `None` merely empties a slot and does not close it. +Reentrant traversal therefore sees valid shared state. Any supported container needs the same proof before a sealed implementation is added. @@ -1052,6 +1229,12 @@ Non-panicking guards apply failure phases during unwinding. Stores that publish an initialized value or completed transition use release ordering; dependent reads use acquire ordering. +`Ready` is an intentional readiness boundary for ordinary state access. +`S` already exists in `Executing`, but the exec hook may still be populating +its logical state. Permitting exec-time callbacks to read it is a possible +API extension, provided those methods handle incomplete setup explicitly; +Rust initialization alone does not require or forbid that policy. + Unreachability alone does not justify an exclusive Rust payload borrow: decref may reenter traversal and free-threaded access needs synchronization. Derived clear removes edges through their shared `GcSlot` interface before @@ -1166,7 +1349,7 @@ through the ordinary error path, with the context available again. A fixed message does not make reporting allocation-free or callback-free: exception creation can allocate, and replacing an exception can run finalizers. -Mandatory registration, restoration, and lifecycle guards must not unwind. +Mandatory entry, restoration, and lifecycle guards must not unwind. A panic already in flight restores attachment before the inner catch receives it. The outer catch is a last-resort fatal boundary, not the normal path for reporting author panics. Cleanup drain failures and inability to maintain the @@ -1188,11 +1371,12 @@ Before `drop_in_place`, deallocation commits the destroyed phase and installs guards for the Python allocation and heap-type reference. A panic therefore cannot cause a second Rust drop or skip mandatory cleanup. Module state uses the same pattern. Generated lifecycle glue holds exclusive context -access and suspends the record around arbitrary payload/state destructors +access and suspends the context around arbitrary payload/state destructors and any C cleanup that may reenter. These destructors receive no context, so they cannot be responsible for calling `ctx.interop` themselves. Restore the -exact state and resume the context before reporting a panic. The record, -release sink, and lifetime protection remain live throughout this cleanup. +exact state and resume the context before reporting a panic. The entry's +release sink, lifetime protection, and state protection remain live +throughout this cleanup. During finalization, the same suspension preserves cleanup-phase restrictions; it does not authorize unrestricted foreign interoperation. Only transitions @@ -1272,6 +1456,13 @@ documented guarantee are rejected before allocation. The allocation records initialization, clear, and drop phases so callbacks never read uninitialized or destroyed `C`. +Generated instance allocation uses `_PyType_AllocNoTrack`, writes valid Rust +phase metadata and the payload, and calls `PyObject_GC_Track` only after +traversal can safely read them. `PyType_GenericAlloc` already tracks GC +objects and therefore does not implement this sequence. Keep the instance +unpublished until tracking and its possible debug traversal finish; zeroed +allocation bytes alone do not make arbitrary Rust values initialized. + `ClassDef::create(ctx, ModuleRef)` combines static slots with the runtime module pointer in the same invariant scope and returns `Bound>`. It verifies the unique `C::definition()`; a second static @@ -1292,8 +1483,9 @@ and entry lifetimes. Rust structs in classes must be `Send + Sync + 'static`. Python methods receive only `&self`, never `&mut self`, because aliases can call the same object concurrently in a free-threaded interpreter. Mutable payload state therefore requires interior -mutability. The rules for a blocking interpreter-aware lock remain an open -decision listed in the appendix; an ordinary `Mutex` must not be presented as +mutability. The appendix describes a `PyLock` prototype with an exclusive +context loan and guarded detachment. Its reentry, panic-recovery, and output +buffer policies remain open; an ordinary `Mutex` must not be presented as the general solution until those rules are settled. @@ -1382,7 +1574,7 @@ its support declarations: directly or through scoped TLS while that context is exclusively borrowed. Include attachment-dependent views held across detach, protocol calls, explicit decref, and release drains. Compile-pass cases must allow opaque - handles and non-`Send` Rust captures/results in same-thread `detach`, and + handles and non-`Send` Rust captures/results in both detachment variants, and exact immutable bytes views whose owner and entry outlive detachment. - Regressions modeled on PyO3 #2141 and #3640: `SendWrapper`, a capture-free function reading scoped TLS, and dynamic exclusive TLS/`RefCell` borrowing. @@ -1393,14 +1585,25 @@ its support declarations: including a TLS hook holding A's values. Repeat with a different thread state in A. Old contexts stay inaccessible; callback scopes use the actual state, and return/unwind restores the exact outer state. An extensible - conversion trait must receive an available context, not one whose record - was suspended around the whole trait invocation. + conversion trait must receive an available context, not one that was + suspended around the whole trait invocation. - Cooperating foreign wrappers under `interop`, allowed nested detachment, - and native preflight refusal with unchanged registration and attachment. + and native preflight refusal with the flag and attachment unchanged. Subprocess tests for uncooperative public and internal detach/swap calls must fail before invalidating an available context. Include direct Rust TLS access from the detached closure, without a generated callback entry. - Forgetting public handles must not affect registration or lease ownership. + Forgetting public handles must not affect the flag or lease ownership. +- Strict `detach` refusal must not run its closure. `detach_if_possible` + must run it exactly once on either the detached path or the attached + fallback, preserve its return value or panic, and restore the exact state. + Include cleanup-phase fallback, permitted nested callbacks, and rejection + of forbidden foreign transitions from the closure; fallback does not grant + additional cleanup permissions. +- Contended `PyMutex` and critical-section parking must use the audited + internal transition path without rejecting a valid leaf operation. Paths + that handle signals or pending calls must use exclusive access. Verify + exact restoration, no callbacks during exempt parking, and lifetime + protection throughout; public release calls cannot claim the exemption. - Two simultaneous subinterpreters, including separate-GIL configurations: distinct module state, runtime classes, and exceptions; checked binding, conversion, explicit destruction, and traced-field publication/mutation @@ -1410,13 +1613,19 @@ its support declarations: pointer equality or immortality does not permit relabeling a stored handle. Interpreter destruction and runtime reinitialization cannot revive old keys, even if allocator addresses or public interpreter IDs are reused. + Include an old interpreter view resolved after ID reuse, and static + singleton drop elision before and after an upcast loses that knowledge. - Attached, detached, wrong-interpreter, and unwinding drops: one ownership transfer and at most one decref per handle, correct interpreter on release, and preservation of any pending exception. Include TLS/native-thread drops after finalization and `Py::drop_with` mismatch behavior. An implicit drop while a shared context is exposed must never run a finalizer synchronously. - Exercise inline sink overflow, nested scopes, and suspended outer sinks; - no node or lookup may retain a pointer to a retired entry. + Exercise inline sink overflow and nested scopes, including a `Py` drop + inside a nested entry, which must use the interpreter queue; no queue node + may retain a pointer to a retired entry. + Exercise the sink aliasing invariant with reentrant and unwinding drops + during a batch drain, including Rust aliasing checks where the harness + supports them. - Fault-injected queue allocation failure, full ordinary pending-call queues, and admission/notification races with shutdown: accepted requests drain before teardown; rejected requests neither dereference nor decref objects. @@ -1427,11 +1636,27 @@ its support declarations: of queued drops from module/type teardown after the first final drain. Include an active consumer with an empty queue whose C deallocator detaches: its batch and interpreter must remain protected until draining completes. + A finalizing thread waiting for outstanding guards must wake and service + new releases, including submissions racing entry into its native wait. + Also pause a producer after its node is drained but before its submission + bookkeeping finishes: producer completion must wake the waiting finalizer. + Cover completion racing entry into the wait and retain last-guard wakeup + coverage. - Shutdown racing with an active detached Rust scope: its state remains live through reattachment; cleanup reentries after normal admission closes work and cannot request explicit detach. Test audited internal temporary detach during cleanup as well. Repeat cross-extension reentry with two `cdylib`s - to verify CPython-owned OS-thread registrations and lifetime bookkeeping. + to verify the CPython-owned flag and lifetime bookkeeping. Nested entries + must still acquire their own leases while a finalizing thread waits for the + guard count to reach zero. Deleting a protected thread state from another + OS thread must be rejected, including a state protected only by a + suspended entry. +- A scoped native worker holds `&S` or `&C` while its entry thread blocks in + a Python callback that detaches internally, without calling Rust `detach` + or `interop`. Finalization must wait and the borrowed storage must remain + valid. Use barriers to establish that ordering, then unblock the callback + and worker and verify shutdown completes. Include free-threaded deferred + module/function roots; a timeout-only hang test is insufficient. - Panic during detachment, parsing, conversion, and payload destruction: exact attachment is restored, the entry lease survives panic reporting, and a second panic or failed restoration cannot cross the C ABI. Include @@ -1440,12 +1665,23 @@ its support declarations: same-interpreter state transfer works and foreign errors are rejected. - Fork with queued references and paused detached producers, including surviving-thread scope restoration and child-side interpreter deletion: - no inherited lock, orphaned in-flight count, or stale scope record prevents - progress or allows access to a removed interpreter. + no inherited lock, stale flag, or orphaned in-flight, lease, or protection + count prevents progress or allows access to a removed interpreter. - Concurrent GC-slot reads, replacement, traversal, and clear: returned owned references remain live, foreign slots cannot be published, reentrant visits never overlap an exclusive payload borrow, closed slots reject replacement, and heap-type/module cycles are collected even for Rust-only payloads. + Failed `replace` must return the identical incoming ownership without an + incref or queued release until the caller drops it. Cover both stopped-world + traversal and debug tracking of a fully initialized unpublished instance. + +Before adopting the `PyLock` prototype, add compile-fail coverage for a second +acquisition through the borrowed context and for using `guard.ctx()` across +guarded detachment. Test detached mutation, unwind restoration, cross-thread +exclusion, and the selected fresh-context recursive-acquisition policy. +Forgetting a guard may leave a locked mutex, but cannot leave stale scope +bookkeeping. Test partial-failure recovery independently of poisoning and +keep GC-visible Python edges outside unsupported lock storage. Run the relevant runtime cases on GIL and free-threaded builds. Also verify that shared Rust payload references cannot expose unsynchronized interior @@ -1455,17 +1691,22 @@ Measure leaf access, unbound binding, outermost and nested entry, exclusive call suspension/resumption, GC-slot access, explicit decref, inline scoped drops, and interpreter-queue overflow against equivalent C operations. Include contention, tiny frequent callbacks, compression throughput, and -retained-memory measurements. The intended cost distribution is: +retained-memory measurements. Measure transition-hook overhead on Python/C +workloads that never enter Rust, both detached and attached fallback CPU work, +and any full-output copy against the current `PyBytesWriter` path. The intended +cost distribution is: - Attachment and phase checks at scope entry and transition boundaries, with no repeated TLS validation for consecutive leaf operations. -- Per-OS-thread registration changes around potentially reentrant calls; - no global registry lookup or allocation on the ordinary suspension path. +- A thread-local flag write around potentially reentrant calls; no global + registry lookup, atomic operation, or allocation on the ordinary suspension + path. - Key comparisons at unbound-handle and traced-field publication boundaries, - and finalization admission bookkeeping at protected entry boundaries. + and lease admission plus a protection count update at every entry, nested + entries included. - Inline enqueue for ordinary scoped drops, batched decref, and native queue - synchronization/allocation on overflow or unbound submissions without a - matching sink. Explicit release avoids that queue traffic. + synchronization/allocation on overflow and for every `Py` drop. Explicit + release avoids that queue traffic. - Refcount traffic for mutable GC-slot reads; no copy for exact immutable bytes input or fresh allocation merely to capture an existing C exception. @@ -1498,9 +1739,19 @@ The following items are intentionally kept out of the normative API sections: initial zero-copy input path under a narrower ownership contract. - **Interpreter-aware locking.** The design must either provide a lock which cooperates with attachment, the GIL, and free-threaded stop-the-world pauses, - or prohibit blocking, detaching, calling Python, and reaching safepoints - while an ordinary Rust lock guard is held. Poisoning and traversal behavior - also require an explicit policy. + or constrain lock use to separately audited patterns, such as acquiring + a Rust-only lock inside a detached computation. The prototype below covers + another useful pattern but does not settle all locking policy. +- **Shutdown progress and module migration.** Per-entry leases intentionally + make shutdown wait for live Rust borrows and accepted entry-local releases. + A daemon thread blocked in a Python callback beneath a Rust entry can + therefore delay shutdown indefinitely. Modules that routinely invoke user + callbacks, such as a future Rust `functools` or `itertools`, need an explicit + migration decision about that behavior. Compute-oriented modules have less + routine exposure, but even `zlib` argument conversion can invoke an + arbitrarily blocking `__index__`; "short protocol hook" is not a lifetime + or progress guarantee. Restricting a future entry mode to opaque handles + would require a separate API proof and cannot weaken the current lease rule. - **Additional GC containers.** Each container must prove interpreter association at publication and mutation, correct edge accounting, safe concurrent traversal/clear, and nonblocking visitor access before gaining a @@ -1513,3 +1764,73 @@ The following items are intentionally kept out of the normative API sections: cleanup, and report errors. A `panic=abort` build remains ABI-safe but cannot provide that recovery behavior. Any future `no_std` configuration must use panic-abort or provide another complete no-unwind boundary. + +#### Interpreter-aware locking prototype + +This is a candidate interface for Rust-only mutable payload state, not yet +a supported traced-field container or a completed locking API: + +```rust +impl PyLock { + pub fn lock<'g, 'py>( + &'g self, + ctx: &'g mut ThreadContext<'py>, + ) -> Guard<'g, 'py, T>; +} + +impl<'g, 'py, T> Guard<'g, 'py, T> { + pub fn ctx(&self) -> &ThreadContext<'py>; + + pub fn detach( + &mut self, + f: impl FnOnce(&mut T) -> R, + ) -> Result; +} +``` + +Acquisition uses an audited `PyMutex` wait that parks detached without +dispatching pending calls. The exclusive context loan lasts until the guard +ends, so a second acquisition through that same context is a compile error. +`ctx()` permits shared leaf access only; a live borrow of its result prevents +an overlapping `Guard::detach` call. The guard remains `!Send + !Sync`. +The lock's cross-thread `Send`/`Sync` implementations require `T: Send` and +an audited synchronized representation, as for a mutex. + +`Guard::detach` lends `&mut T` to its closure while retaining the native +mutex for the entire call. It uses strict detachment and reports phase +refusal before running the closure. The mutable borrow cannot escape in +`R`. Private guards restore the exact thread state and availability flag +on return or panic independently of the public guard's eventual destruction. +The native lock must never be automatically released by a CPython critical +section or parking mechanism while any Rust reference to its protected data +is live; otherwise another caller could obtain overlapping mutable access. + +This prevents exclusive Python operations through the original context, +not every possible Python call while locked. The detached closure can use a +foreign wrapper to attach and invoke a generated callback with a fresh +context. That callback can attempt the same lock and deadlock, despite obeying +all scope brands. The API must choose and document ordinary nonrecursive +mutex behavior or native recursive-acquisition detection and an error result. +Lock ordering and cross-lock cycles remain separate progress concerns. + +The initial GC field policy does not accept Python edges hidden behind this +lock: traversal cannot acquire it or alias a live `&mut T`. Any future traced +lock container needs its own publication, traversal, and clear proof. A panic +may leave application state inconsistent, so a recovery or invalid-state +policy is required. Poisoning can aid recovery but cannot be the basis of +memory safety; payload methods and destructors must remain safe after partial +failure. Safe `mem::forget(guard)` may leave the mutex permanently locked and +end the Rust context loan. It must not leave the availability flag stale +or make restoration or interpreter lifetime protection depend on its drop. + +For `zlib`, this interface can hold a stream lock across detached compression, +but output growth must also fit the context rules. One candidate grows a +Rust-owned output buffer inside a single detached closure, unlocks, and then +copies into Python bytes. The current C `_BlocksOutputBuffer` delegates to +`PyBytesWriter`, whose finish operation can return its existing bytes object +after any needed resize. It does not always perform a final block-joining +copy. The Rust-buffer candidate can therefore add a full-output copy and +increase peak memory. Benchmark it against the current writer before choosing +it; an audited unpublished-output abstraction may offer another approach. +The prototype is not sufficient grounds to claim C-equivalent performance +or enable the first full `zlib` migration. From bf36650ce400c52a6815f1c677fbd9503f6a6973 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Wed, 14 Oct 2026 19:12:09 -0700 Subject: [PATCH 04/16] Revised API design document --- RUST_API.md | 2268 ++++++++++++++++++++------------------------------- 1 file changed, 873 insertions(+), 1395 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index 88124de01f2df6c..4fd805b4e50c79f 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -1,24 +1,23 @@ -# Rust for CPython API Design Doc for 3.16 +# Rust for CPython: API Design for 3.16 -### Overview +## Overview -This document designs a safe Rust API for in-tree CPython modules, using -`zlib` to drive the initial requirements and providing a base for later Rust -work. +This document designs a safe Rust API for in-tree CPython extension modules. +`zlib` is the first full migration target and drives the initial +requirements; `_base64` and `Encoder` examples illustrate individual +features. Keeping either module's buffer-accepting interface needs the +buffer work in the appendix, and stateful `zlib` also needs the locking and +output-buffer designs there. -`zlib` is the first full migration target. The `_base64` and `Encoder` -examples below are used to illustrate individual API features. Preserving -either module's buffer-accepting interface requires the buffer work in the -appendix. Stateful `zlib` additionally requires the locking and output-buffer -designs discussed there. +Rust can call the C API efficiently, but raw FFI does not encode Rust's +lifetime, aliasing, or thread-safety rules. The wrapper described here +restores them. It depends on new private CPython support (a per-thread +availability flag, per-thread-state protection and lease counts, transition +gates, and a native release queue), so it is not a safe model for an +unmodified runtime. Several existing CPython bugs also interact with it; the +appendix lists them as upstream prerequisites. -### Context - -Rust can call CPython's C API efficiently, but raw FFI does not encode Rust's -lifetime, aliasing, or thread-safety rules. A safe wrapper is needed to retain -those guarantees when implementing CPython modules in Rust. - -### Goals +## Goals In priority order, the API should: @@ -29,48 +28,54 @@ In priority order, the API should: 4. feel familiar to PyO3 and C API users; and 5. use modern APIs such as `PyModExport`. -Every use of `unsafe` requires a `// SAFETY:` comment stating its proof -obligation. No panic may escape a non-unwinding C ABI, as this is undefined behavior in Rust. - -### Non-goals - -Full C API parity, a stable ABI, and sharing of Python objects -between interpreters are out of scope. Wrappers are added as modules implemented in Rust need -them. - -### Specific guidelines - -- Encode CPython contracts in the type system where practical. -- Control compile times -- Preserve Rust's initialization, aliasing, and concurrency invariants. -- Support both GIL and free-threaded builds. - -### API design - -1. Bindings and the FFI boundary -2. Interpreter identity and lifetime -3. Managing the thread context -4. Python object handles and garbage collection -5. Deferred reference release -6. Exceptions and error handling -7. Concrete object abstractions -8. Rust/Python conversions -9. Module definition -10. Function definition -11. Class and method definition -12. Argument parsing -13. Testing -14. Open decisions and future work - -#### Bindings and the FFI boundary - -The `cpython-sys` crate generates raw bindings at build time from `Python.h`, internal -headers, and the active `pyconfig.h`. This captures platform, target, debug, -and free-threading-dependent layout information. Bindgen allowlists public and private -`Py*` and `_Py*` names. Some C constructs are not replicatable via C bindings such as macros. -These constructs are replicated in Rust. - -Python objects allow mutation through shared pointers, so their binding uses +It encodes CPython contracts in the type system where practical, preserves +Rust's initialization, aliasing, and concurrency invariants, and keeps +compile times under control. + +**Non-goals:** full C API parity, a stable ABI, and sharing Python objects +between interpreters. Wrappers are added as Rust modules need them. + +### Safety rules + +- Every `unsafe` has a `// SAFETY:` comment stating its proof obligation. +- No panic may escape a non-unwinding C ABI. +- Generated code never expands author-supplied expressions, defaults, or + calls inside an `unsafe` block; it binds them in safe items first. +- A trait whose implementations unsafe code trusts (a type check, type + pointer, or class identity) is `unsafe` or sealed. Otherwise a crate with + `#![forbid(unsafe_code)]` could cause undefined behavior by implementing + it. +- A type parameter carrying an identity that unsafe code relies on (a class + payload, module state, or traced slot's contents) is invariant. + `Payload` is a subtype of `Payload`, so a + covariant parameter would let safe code relabel one as the other. +- A typed handle is created only after a type check, or from a C function + whose result type Python code cannot choose. (A metaclass, for example, + decides what `type(name, bases, dict)` returns.) + +## API design + +1. [Bindings and the FFI boundary](#bindings-and-the-ffi-boundary) +2. [Interpreter identity and lifetime](#interpreter-identity-and-lifetime) +3. [The thread context](#the-thread-context) +4. [Python object handles](#python-object-handles) +5. [Deferred reference release](#deferred-reference-release) +6. [Exceptions](#exceptions) +7. [Concrete objects and conversions](#concrete-objects-and-conversions) +8. [Modules](#modules) +9. [Functions](#functions) +10. [Classes](#classes) +11. [Argument parsing](#argument-parsing) + +### Bindings and the FFI boundary + +`cpython-sys` generates raw bindings at build time from `Python.h`, the +internal headers, and the active `pyconfig.h`, capturing layout that depends +on platform, target, debug, and free-threading configuration. Bindgen +allowlists public and private `Py*`/`_Py*` names; constructs it cannot +translate, such as macros, are reimplemented in Rust. + +Python objects are mutable through shared pointers, so the binding uses `UnsafeCell`: ```rust @@ -78,127 +83,137 @@ Python objects allow mutation through shared pointers, so their binding uses pub struct PyObject(UnsafeCell<_object>); ``` -All C calls pass through a private `c_api` module. A safe operation that may -touch Python requires a borrow of `ThreadContext`, or the private restricted -capability appropriate to traversal or reference release. Shared context -access is reserved for audited leaf operations; operations that can run -callbacks or change attachment require exclusive access. The scope protocol -below maintains attachment without a TLS check at every leaf operation. -An operation without a capability needs an audited proof that its entire -call path is thread-state-free. Ordinary `Drop` only submits releases or -applies the statically immortal singleton exception below; it never obtains -a capability to decref synchronously. Existing C APIs may still obtain the -current state internally. +All C calls go through a private `c_api` module. Every safe operation that +may touch Python requires a capability: a borrow of `ThreadContext` (shared +for audited leaf operations, exclusive for anything that can run callbacks +or change attachment), or a restricted private capability for GC traversal +or reference release. An operation without one needs an audited proof that +its whole call path is thread-state-free. Until there is async `Drop`/`Drop` with context, `Drop` never obtains a +capability; it only submits a release. -#### Interpreter identity and lifetime +### Interpreter identity and lifetime + +#### Interpreter keys Each interpreter lifetime has a private `InterpreterKey`: a nonzero 64-bit -generation number from a process-wide counter that must survive -`Py_FinalizeEx` and the runtime reset in `_PyRuntimeState_Init`. Every -interpreter, including a main interpreter created after reinitialization, -receives a fresh key; exhaustion fails interpreter creation rather than -reusing one. Public interpreter IDs cannot serve this role: -`_PyInterpreterState_Enable` resets `next_id`, the main interpreter's ID must -be zero, and `PyInterpreterView_FromMain` hardcodes that value. For the same -reason, an interpreter view, which resolves by public ID, cannot prove -generation identity after reinitialization. The key adds one field per -interpreter and no extra word to each Rust handle. +generation number from a process-wide counter that survives `Py_FinalizeEx` +and the runtime reset in `_PyRuntimeState_Init`. Every interpreter, +including a main interpreter created after reinitialization, gets a fresh +key; exhaustion fails interpreter creation. Public interpreter IDs cannot +serve this role, because `_PyInterpreterState_Enable` resets `next_id` and +the main interpreter is always ID zero. For the same reason an interpreter +view, which resolves by ID, cannot prove generation identity. A key names the interpreter under whose API contract a reference was -acquired and may be used, not the interpreter that allocated its storage. An -accessor called in interpreter B may return a B-associated handle to a -process-global constant or static type that originated in the main -interpreter. An existing A-associated handle is never relabeled B merely -because the object is immortal or the pointers compare equal. Only an audited -CPython contract can authorize sharing; ordinary cross-interpreter transfer -reconstructs the object through a separately audited conversion. - -Objects carry no interpreter association, so there is no generic -object-to-interpreter lookup and no `PyObject` header change is required. The -free-threaded `ob_tid` records refcount thread ownership, not interpreter -identity. Generated glue reads the key from the attached thread state's -interpreter, which needs no allocation, registry search, or lock, and -preserves it through handle conversions. - -A key identifies an interpreter but does not keep it alive. That is the job -of an interpreter-finalization lease, held by each Rust entry for its whole -duration, including while detached. Finalization waits until no lease -remains. Nested entries acquire their own leases rather than searching for -an enclosing one. While an enclosing entry holds a lease on the same -interpreter, finalization cannot close admission, so the nested acquisition -succeeds. Private entry glue owns the lease and releases it -last, on return or unwind, after every scoped value has been consumed, queued -releases have been drained or transferred, and panic reporting has finished. -Safe APIs must not start, or block waiting for, finalization of an -interpreter whose lease they hold, since that finalization would wait on -their own lease. A stored `Py` never holds a lease; otherwise a `Py` in -module state would block the shutdown that destroys that module state. - -The lease must span the whole entry, not just explicit `detach` and `interop` -calls. Attachment already protects the entry thread itself, because -finalization hangs a paused thread rather than resuming it. It does not -protect borrows the entry has lent out: `&S`, `&C`, and `&[u8]` point to -`Sync` data, so safe code can pass them to scoped worker threads. +acquired, not the one that allocated the object. An accessor in interpreter +B may return a B-associated handle to a process-global constant, but an +existing A-associated handle is never relabeled B because the object is +immortal or the pointers are equal. + +Objects carry no interpreter association. Generated glue reads the key from the attached thread state's +interpreter, with no allocation, search, or lock, and conversions preserve +it. + +#### Finalization leases + +A key does not keep its interpreter alive. Each Rust entry therefore holds a +**lease** for its whole duration, including while detached, and +finalization waits until no lease remains. + +- Nested entries take their own leases. While an enclosing entry holds one, + admission cannot close, so nested acquisition succeeds. +- Entry glue releases the lease last, after scoped values are consumed, + queued releases are drained or transferred, and panic reporting finishes. +- A stored `Py` never holds a lease, or a `Py` in module state would + block the shutdown that destroys it. + +The lease must cover the whole entry, not just explicit detaches. Attachment +protects the entry thread itself, since interpreter finalization hangs a paused thread. Attachment does not protect borrows the entry has lent out. `&S`, `&C`, and `&[u8]` point to +`Sync` data, so they can reach scoped worker threads: 1. An entry on a daemon thread lends `&S` to a scoped worker. -2. The entry thread calls Python, which detaches internally, for example in - `socket.recv`. -3. Finalization hangs the daemon thread on reattach and removes its thread - state. +2. The entry calls Python, which detaches internally (e.g. `socket.recv`). +3. Finalization hangs the daemon thread on reattach and removes its state. 4. The worker is still reading `S`. -On free-threaded builds, module objects and module-level functions use -deferred reference counting, and the collector finds their stack references -only through registered thread states. After step 3 those references are no -longer roots, so the module and `S` can be freed while the worker reads `S`. -Leasing only explicit detaches would not help, because step 2 is not one, and -a bare Rust reference has no destructor with which to release a lease of its -own. The whole-entry lease also keeps the entry's release sink valid until it -is drained. - -The cost is that a Rust entry blocked indefinitely in a Python callback also -blocks interpreter shutdown indefinitely; the appendix discusses the -migration impact. This is deliberate and must not be weakened by expiring the -entry's references or abandoning its accepted releases. - -[PEP 788 interpreter guards](https://peps.python.org/pep-0788/) are the basis -for the lease mechanism. A new private -stack-based entry API uses the existing `try_acquire_interp_guard` CAS, -avoiding a guard allocation per callback. A decrement-and-unpark operation -factored out of `PyInterpreterGuard_Close` lets a stack guard release its -lease without freeing its storage. Acquisition must synchronize with -finalization's admission barrier; checking a finalizing flag before -incrementing a counter is insufficient. Once admission closes, normal entry -fails before exposing any Rust reference. - -Callbacks that CPython legitimately invokes during finalization, and -synchronous reentrant callbacks caused by them, instead receive private -cleanup authorization from the finalizing thread. Their contexts last only -for that callback and cannot request explicit detachment or unrestricted -foreign-library interoperation. An audited synchronous C call may detach -internally only if the authorization protects its state through restoration -and finalization cannot advance until the call returns. Neither user code nor -an arbitrary foreign thread can manufacture cleanup authorization, and -traversal cannot turn its restricted visitor capability into a general -context. +On free-threaded builds, modules and module-level functions use deferred +reference counting, and their stack references are roots only through +registered thread states. After step 3 the module and `S` can be freed +under the worker. The whole-entry lease also keeps the entry's release sink +valid until drained. + +The cost is that an entry blocked indefinitely in a Python callback blocks +shutdown indefinitely (see *Shutdown progress* in the appendix). This is +deliberate. + +Two fatal checks prevent leases from deadlocking: + +- **Finalizing a leased interpreter.** A Rust callback may run code that + calls `Py_EndInterpreter` on an interpreter it holds a lease on. When + finalization starts, CPython fails fatally if the finalizing OS thread + holds a lease on that interpreter through any of its thread states. +- **Hanging a leased thread.** A thread may hold a lease on A while running + in B (e.g. via `interp.exec()`). If B's finalization hung it, A could + never finish finalizing. CPython's hang paths, including the one in + `PyGILState_Ensure`, therefore fail fatally when the thread holds any + lease. Direct calls to the public `PyThread_hang_thread` bypass this. A + later refinement could make B wait for such threads instead. + +#### Lease protocol + +Leases build on [PEP 788 interpreter guards](https://peps.python.org/pep-0788/). +Acquisition synchronizes with finalization's admission barrier; once +admission closes, entry fails before exposing any Rust reference. Lease +bookkeeping lives in the entry guard on the stack, so entry allocates +nothing. + +Leases do not use the guards' shared `finalization_guards` counter, which +would make every entry write one shared cache line. Each thread state counts +its own leases: + +- **Acquire:** read the interpreter's admission-closed flag, then increment + the attached state's lease count, atomically and with no safepoint + between. +- **Release:** decrement the count. If it reaches zero during finalization, + [wake the finalizer](#waking-the-finalizer) and do not touch the + interpreter afterwards. +- **Wait:** admission stays open so lease holders can make nested callbacks. + The finalizer sums the counts under the runtime's thread-list lock and + parks until woken. +- **Close:** once the sum is zero, stop the world (or hold the GIL), recheck + every count, the guard counter, and the release queue, and set the flag + only if all are clear; otherwise restart and wait again. +- A state with a nonzero lease count is protected from deletion. After fork, + vanished states' counts disappear with them. + +PEP 788 C guards keep their shared counter, because a guard can be created +without an attached thread state and closed on another thread. + +#### Cleanup authorization + +Callbacks CPython invokes during finalization, and synchronous callbacks +they cause, receive **cleanup authorization** instead of a lease. After +admission closes, an entry is authorized if and only if its attached thread +state is the one finalizing that interpreter. Cleanup contexts cannot +request explicit detachment or unrestricted interoperation; an audited C +call may detach internally only if finalization cannot advance until it +returns. User code and foreign threads cannot manufacture authorization. It +ends at the [final drain point](#shutdown). + +#### Future native-thread entry The initial API creates contexts only at generated C entries. A future safe -native-thread entry should reuse `PyThreadState_Ensure` and -`PyThreadState_EnsureFromView`, adding generation validation and the scope -protocol below. It must take an explicit interpreter key rather than trusting -view lookup, acquire matching lifetime protection before attaching, and -report shutdown failure without a Python exception while detached. -`PyGILState_Ensure` must not implicitly select the main interpreter. Raw C -callers remain responsible for valid inputs and CPython's interpreter -contracts. - -#### Managing the thread context - -`ThreadContext<'py>` is a scoped, exclusive capability for one exact CPython -thread state on the current OS thread. Whenever safe code can use the -context, that state is attached and protected against finalization. It is -not enough for some state in the same interpreter to be attached: exception -state and other thread-local CPython state belong to the exact thread state. +native-thread entry should build on `PyThreadState_Ensure` and +`PyThreadState_EnsureFromView`, take an explicit interpreter key rather than +trusting view lookup, acquire a lease before attaching, and report shutdown +failure without a Python exception. `PyGILState_Ensure` must not implicitly +select the main interpreter. + +### The thread context + +`ThreadContext<'py>` is a scoped, exclusive capability for one exact thread +state on the current OS thread. Whenever safe code can use it, that state is +attached and protected against finalization. ```rust pub struct ThreadContext<'py> { @@ -210,15 +225,11 @@ pub struct ThreadContext<'py> { } ``` -The function marker makes `'py` invariant, and the raw-pointer marker makes -the context `!Send + !Sync`. It is neither `Copy` nor `Clone`; only generated -entry glue constructs it and lends it to author code. `Bound` and `Borrowed` -are also invariant in `'py`. This is a fresh entry-scope brand, not a lifetime -chosen once per interpreter: separate entries cannot mix scoped values -directly, even in the same interpreter. Unbind and checked bind explicitly -move ownership between scopes. - -There are two classes of Python access: +The marker makes `'py` invariant and the context `!Send + !Sync`. It is +neither `Copy` nor `Clone`, and only generated entry glue constructs it. +`'py` is a fresh brand per entry, and `Bound` and `Borrowed` are invariant in +it too, so separate entries cannot mix scoped values even within one +interpreter. | Context argument | Permitted operations | Examples | | --- | --- | --- | @@ -226,133 +237,91 @@ There are two classes of Python access: | `&mut ThreadContext<'py>` | Operations that may execute Python, call user code, allocate through a potentially reentrant path, decref, drain releases, detach, or switch states temporarily. | Generic extraction, object creation, attribute access, explicit destruction, and `detach`. | The classification covers the entire call path, including failure handling, -allocator hooks, synchronization, and incidental cleanup. A shared-context -operation returns native errors if reporting a Python error would require -exclusive access. A new wrapper defaults to exclusive access until its leaf -contract has been audited. A blocking acquisition is not a leaf merely -because its successful fast path is short. Attachment does not itself -protect object contents against concurrent mutation on a free-threaded build. - -Author functions may accept `&mut ThreadContext` and reborrow it as shared -for consecutive leaf operations. Taking `&mut ThreadContext` does not make -it unavailable automatically. A private wrapper suspends the capability only -around the actual potentially reentrant C call or closure; it never passes -that suspended context to author code, trait implementations, or callbacks. -Argument conversion and return conversion implementations receive an -available context and use the same wrappers as other Rust code. - -##### Entry and attachment transitions - -This design requires new private CPython support. Rust borrowing alone -cannot stop a different safe Rust library from calling `PyEval_SaveThread` -without borrowing this context. Checking only when a generated callback is -entered is also insufficient: a detached Rust closure can call scoped TLS -without crossing a generated C entry. - -CPython keeps a private thread-local flag on each OS thread recording whether -a Rust context is **available** there, shared across all extension libraries -and all interpreters on that thread. A context is **suspended** while a -private wrapper holds its exclusive borrow and exposes no access to it. At -most one context is available on an OS thread, so one flag is enough, and -CPython keeps no stack of scope records. Nesting follows the machine stack -instead: entry and restoration guards are lexically scoped and cannot be -forgotten, and nested entries are admitted only while the flag is clear, so -each transition below knows the flag's previous value. Nested entries create a -fresh scope for the state actually attached at that entry. - -The flag lives in CPython rather than in a Rust `thread_local!` because each -extension links its own copy of the Rust support crate. Only the owning thread -reads or writes it, since attach, detach, and swap act on the calling thread, -so it needs no atomic access. It is per OS thread rather than per -`PyThreadState`, so the gates can consult it whichever state, if any, is -attached. - -Each `PyThreadState` also gets an atomic Rust protection count. An entry -increments the count of the state attached at entry and decrements it only at -exit, so a suspended entry still protects its state. Release consumers do the -same while they hold a batch. The count is atomic because deletion can run on -another OS thread. - -The required private operations have these contracts; their names and C -layout are implementation details: - -1. **Enter:** require the flag to be clear. Verify the actual attached state - and execution phase, acquire interpreter lifetime protection, and increment - the attached state's protection count. Only then set the flag and publish - the context; entry cannot publish a context before all these steps succeed. - A per-interpreter lease does not by itself keep an individual thread state - alive; the protection count does, until restoration and exit. -2. **Suspend:** while holding `&mut ThreadContext`, clear the flag before - invoking a potentially reentrant operation. Native preflight checks leave - attachment and the flag unchanged on refusal and do not create a Python - exception. No arbitrary callback occurs between preflight and committing - suspension. The private restoration guard retains the exact original state - and lifetime protection. Suspension removes access to the Rust capability; - it does not broaden the C operations permitted by the execution phase. - Cleanup authorization and its transition restrictions remain in force. -3. **Transition:** CPython's attach, detach, swap, and thread-state deletion - paths reject any transition while the flag is set. They consult the flag - even when there is currently no attached state, and cover both public and - internal transition paths. Deletion additionally rejects a state whose - protection count is nonzero, even when attempted from another OS thread; - this covers suspended entries and active consumers, which the deleting - thread's own flag cannot see. Destruction cannot synchronously wait for a - protected entry on its own call stack to finish. -4. **Resume:** nested entries have already exited, because they run inside the - suspended call. Require the flag to be clear and the exact original state - to be attached, then set the flag again. Restore the original state on - normal return and Rust unwind according to the operation's audited - contract. If a foreign call violates its restoration contract, fail without - exposing a stale context; do not silently attach over an unknown state. - Failure to restore safely is fatal. -5. **Exit:** after author code and scoped handles are finished, clear the - flag, then service or transfer the entry's releases while retaining the - sink and lifetime protection for reentrant cleanup. Retire the sink only - after it is empty. Cleanup and panic reporting precede decrementing the - state's protection count and releasing the lease. - -Entry and restoration guards are private and cannot be returned or forgotten -by safe author code; the single flag depends on this. Forgetting a public -handle may leak a reference but cannot clear the flag, remove attachment -protection, or strand a suspended context. The entry guard, rather than the -public context, owns the release sink and the entry's private state, at a -stable address established without an allocation for each entry. - -A controlled Rust wrapper checks detachment permission before calling -`PyEval_SaveThread`, so that C API needs no new recoverable failure result -for this path. Legacy C transitions that bypass the protocol while the -flag is set must fail-stop before changing attachment; silently -continuing would invalidate safe Rust's proof, and waiting on the same thread -would deadlock. A new private checked transition API may return a native -status instead. Scope-entry protocol violations likewise fail before exposing -Rust values. These requirements must be implemented in CPython before leaf -operations can omit attachment checks; this is not a safe model for an -unmodified runtime. - -Audited internal parking for stop-the-world or synchronization can be -exempted only if no arbitrary callback or Rust access can run until the exact -state is restored, and the leaf operation exposes no attachment-dependent -access during that interval. Otherwise it must use suspension. This audit -includes free-threaded safepoints and object critical sections, not only -`Py_BEGIN_ALLOW_THREADS`. - -Implement that exception with a private balanced-parking transition or -private transition reason. The current parking lot calls public -`PyEval_ReleaseThread` and `PyEval_AcquireThread`, so gating public functions -alone does not distinguish explicit detachment from internal parking. The -exemption must remain confined to audited paths that restore the same state -before any callback or Rust access; an arbitrary C caller cannot request it. -Lock paths that handle signals by calling `Py_MakePendingCalls` require the -exclusive-call protocol instead. No exemption permits deletion of protected -states or bypasses interpreter lifetime protection. - -Placed beside CPython's existing thread-local current-state pointer, the flag -makes the common gate one load and branch; deletion adds a load of the state's -count. Transition hooks also affect non-Rust code. Benchmark them on ordinary -C and Python workloads rather than assuming zero overhead. - -Entry uses a higher-ranked closure so callers cannot choose `'py = 'static` -or return a scoped value. Its conceptual private signature is: +allocator hooks, and cleanup. A shared-context operation reports failures +as native errors, since raising would need exclusive access. New wrappers +default to exclusive until audited, and a blocking acquisition is never a +leaf. Attachment does not protect object contents from concurrent +mutation on free-threaded builds. + +Holding `&mut ThreadContext` does not make the context unavailable. A +private wrapper **suspends** it only around the actual reentrant C call or +closure, and never passes a suspended context to author code. + +#### Availability flag and protection count + +Borrowing alone cannot stop another safe Rust library from calling +`PyEval_SaveThread`, and a detached closure can reach scoped TLS without +crossing a generated entry. CPython therefore adds: + +- **An availability flag** per OS thread, recording whether a Rust context + is available there. It is shared by all extensions and interpreters on + the thread. At most one context is available per thread, and entry and + restoration guards are lexically scoped, so one flag suffices and no scope + stack is needed. It lives in CPython because each extension links its own + copy of the Rust support crate. Only the owning thread touches it, so it + needs no atomics, and placed beside the current-state pointer it makes the + common gate one load and branch. +- **A protection count** per `PyThreadState`, incremented at entry and + decremented at exit, so a suspended entry still protects its state. + Release consumers do the same while holding a batch. It is atomic because + deletion can run on another thread. + +#### Scope transitions + +1. **Enter:** require the flag clear; verify the attached state and + execution phase; acquire a lease; increment the protection count. Only + then set the flag and publish the context. +2. **Suspend:** clear the flag before a reentrant operation. Preflight + refusal leaves attachment and the flag unchanged without raising. The + restoration guard keeps the original state. Suspension does not broaden + the C operations the execution phase permits. +3. **Gate:** CPython's attach, detach, swap, and deletion paths, public and + internal, reject any transition while the flag is set, even with no state + attached. Deletion also rejects a state with a nonzero protection count, + even from another thread. +4. **Resume:** require the flag clear and the original state attached, then + set the flag. If a foreign call failed to restore the state, fail fatally + rather than attach over an unknown state. +5. **Exit:** clear the flag, service or transfer the entry's releases, and + retire the sink once empty. Cleanup and panic reporting precede releasing + the protection count and lease. + +Entry and restoration guards are private and cannot be forgotten by safe +code, which the single flag depends on. Forgetting a public handle may leak +a reference but cannot affect the flag or protection. The entry guard owns +the release sink at a stable stack address. + +Rust wrappers check permission before calling `PyEval_SaveThread`. Legacy C +transitions that bypass the protocol while the flag is set fail fatally +before changing attachment: continuing would break safe Rust's proof, and +waiting would self-deadlock. + +#### Gated and exempt paths + +- **Gated:** the internal detach path (reached by every public release and + swap), deletion of the current thread state, `Py_MakePendingCalls`, and the + deprecated `PyEval_ReleaseLock`. Clearing or deleting any state checks its + protection count. +- **Exempt:** `_PyParkingLot_Park`, through a + private thread-local marker, and the `_PyThreadState_Suspend` + stop-the-world safepoint. + +A path is exempt only if no callback or Rust access can run before the +exact state is restored. The parking lot calls the public +`PyEval_ReleaseThread`/`PyEval_AcquireThread`, so the private marker is what +distinguishes it from explicit detachment. Lock paths that call +`Py_MakePendingCalls` for signals use the exclusive-call protocol instead. + +The gate does not stop foreign code from running Python on the attached +state (`PyObject_Call` from another library, or `PyErr_CheckSignals`). That +violates the foreign library's own contract, and every Rust boundary it can +reach checks the flag: a nested entry or gated detach fails fatally, GC +clear of a Rust object is refused (leaking the cycle), and payloads are +dropped without a context as always. Transition hooks also affect non-Rust +code and must be benchmarked. + +#### Entry glue +The following is a simplified example of what the Rust entrypoint glue for a function call looks like: ```rust // SAFETY: only generated C callbacks with an attached state and valid inputs @@ -362,21 +331,15 @@ unsafe fn with_current( ) -> Result; ``` -`for<'py>` makes the lifetime fresh and `R` cannot depend on it. Generated -wrappers convert scoped results to raw C values before returning. Macros -reject author functions that request a specific lifetime such as `'static`. -Callback inputs also have a separate owner lifetime, described under argument -parsing. The actual entry glue keeps its private guard through parsing, -conversion, unwind handling, release processing, and exception reporting. - -`EntryError` is native. Normal admission failure is translated by private -callback-specific reporting glue with its own valid attachment and lifetime -authorization; it must not manufacture a general context after admission -closed. If the callback has neither normal nor cleanup/reporting -authorization, or violates the scope protocol, it cannot safely continue. -No error path may consult a stale context to raise an exception. +`for<'py>` makes the lifetime fresh, so `R` cannot depend on it, and macros +reject author functions that name a specific lifetime such as `'static`. +Generated wrappers convert results to raw C values before +returning. The entry guard lives through parsing, conversion, unwinding, +release processing, and exception reporting. `EntryError` is native; +callback-specific glue reports it without manufacturing a context after +admission closes. -##### Detachment, reentry, and foreign wrappers +#### Detachment and foreign wrappers ```rust impl<'py> ThreadContext<'py> { @@ -399,81 +362,39 @@ impl<'py> ThreadContext<'py> { } ``` -`detach` checks the execution phase, suspends the context, calls -`PyEval_SaveThread` or its private equivalent, executes `f` on the same OS -thread, and restores the saved state before resuming the context. Its private -guard does the same restoration if `f` panics. The interpreter lease and -thread-state protection span the entire detached interval. A refusal returns -a native `DetachError` before detaching; a caller can subsequently translate -it through an available context. Cleanup entries refuse explicit detachment. -There is no outermost-entry permit: a normal nested callback may detach when -all enclosing contexts are suspended and it has lifetime protection. - -`detach_if_possible` makes the fallback explicit: detach when the execution -phase permits, otherwise run `f` while remaining attached. It invokes `f` -exactly once, on the same OS thread, and returns its result unchanged. Only a -legitimate phase restriction selects the attached path; a broken attachment -or scope invariant remains fatal. The exclusive borrow, suspension -around the closure, lifetime protection, and restoration on unwind apply to -both paths. An attached fallback does not grant cleanup code permission for -otherwise forbidden foreign transitions, and does not suppress closure -errors or panics. - -Callers of the fallback operation must tolerate execution while attached. -Straightforward compression or encoding is a suitable use after auditing -its dependencies. A closure that waits for another thread needing Python -progress should use strict `detach` and handle refusal before waiting. -Attached waits can deadlock through the GIL or free-threaded stop-the-world -coordination. The labels "computation" and "waiting" are guidance, not a -type-level effect system; hidden waits and foreign callbacks still matter. - -`interop` suspends the context without initially detaching. Its closure can -use a foreign safe wrapper that manages its own attachment, including -switching interpreters. That wrapper must restore the incoming state before -returning or unwinding; the boundary verifies exact restoration before -resuming the context. Cleanup entries refuse unrestricted interoperation. -Unsafe foreign calls retain their ordinary C and Rust safety obligations. -This boundary protects this API's contexts; it does not repair a foreign -wrapper's own unsound attachment tokens or reference lifetimes. Foreign -wrappers remain responsible for their capabilities and C API contracts. - -None of these closures receives the borrowed context or needs `Send` bounds -on its captures or result: they execute on the same thread, and the proof is -exclusive access to the capability, not an assertion about captured values. -Opaque scoped handles and independently safe Rust data may be moved into or -returned from the closure. Accessing Python through a handle still requires -an available matching context. Binding it in a fresh callback requires an -explicit unbind and checked bind. - -This addresses the mechanisms in PyO3 issues +- **`detach`** suspends the context, detaches, runs `f` on the same thread, + and restores the state, including on panic. Refusal returns `DetachError` + before detaching. Cleanup entries refuse it. Nested callbacks may detach. +- **`detach_if_possible`** detaches when the phase permits and otherwise runs + `f` attached, exactly once either way. Use it only for work valid while + attached, such as audited compression. Code that waits on another thread + needing Python must use strict `detach`, because attached waits can + deadlock through the GIL or stop-the-world. +- **`interop`** suspends without detaching, so `f` can use a foreign safe + wrapper that manages its own attachment, including switching + interpreters. The boundary verifies the incoming state was restored. + Cleanup entries refuse it. It does not repair a foreign wrapper's own + soundness. +- **`drain_releases`** services queued releases (see [Draining](#draining)). + +The closures receive no context and need no `Send` bounds: they run on the +same thread, and the proof is exclusive access to the capability. Handles +may move in and out but remain unusable without an available context. + +This closes the holes in PyO3 [#2141](https://github.com/PyO3/pyo3/issues/2141) and -[#3640](https://github.com/PyO3/pyo3/issues/3640). `SendWrapper` can make a -non-`Send` value transportable while allowing access on its originating -thread, and a capture-free function can reach scoped TLS. Neither property -proves attachment. In this API, placing a shared context reference in scoped -TLS prevents an overlapping exclusive borrow for detachment or reentrant -operations. A sound mutable scoped-TLS abstraction or `RefCell` must retain -its exclusive loan throughout the call; attempted reentrant borrowing fails -rather than yielding an alias. TLS may make opaque handles reachable while -detached, but cannot supply a usable suspended context. Foreign detachment -that does not borrow the context is covered by CPython's transition gate. - -The same rule prevents an outer context for interpreter A from being used by -a TLS hook invoked while a C call has switched to interpreter B. The call -holds the exclusive borrow of A's context for its entire duration; B's -callback receives a distinct scope. Merely promising to restore A when the -C call eventually returns would not be sufficient. - -A Python-touching implicit operation cannot bypass these rules. Handle -`Debug`, equality, hashing, indexing, or iterator operations either use only -Rust metadata or require an explicit context through a named method; no -implicit operation performs Python `repr`, equality, or protocol dispatch. -Ordinary owning-handle `Drop` only queues releases, as specified below. - -#### Python object handles and garbage collection - -Three private generic handle representations distinguish borrowing, ownership, -and attachment: +[#3640](https://github.com/PyO3/pyo3/issues/3640), where `SendWrapper` or +scoped TLS let code reach Python without proof of attachment. Here a shared +context reference stashed in TLS blocks the exclusive borrow that detaching +needs, a `RefCell` holding the context fails a reentrant borrow, and +foreign detachment hits the CPython gate. Likewise a TLS hook running while a +C call has switched to interpreter B cannot use A's context, which stays +exclusively borrowed. + +No implicit operation (`Debug`, equality, hashing, indexing, iteration, +`Drop`) touches Python. + +### Python object handles | Handle | Owns a reference | Lifetime | Auto traits | Drop | | --- | --- | --- | --- | --- | @@ -481,34 +402,20 @@ and attachment: | `Bound<'py, T>` | Yes | guarded entry `'py` | `!Send`, `!Sync` | enqueue in entry release sink | | `Py` | Yes | unbound, for stored state | `Send`, `Sync` | enqueue by interpreter key | -The two `Borrowed` lifetimes are independent: the reference owner supplies -`'a`, while `ThreadContext` supplies interpreter liveness and permission to -access the object within `'py`. `Py` may cross threads, but touching its -object still requires an active context for its associated interpreter. - -`Py` stores a non-null object pointer and an `InterpreterKey`. `Bound` -stores an object pointer and a private pointer to its entry's release sink, -plus the invariant scope brand and thread-affinity marker. The sink records -the interpreter key and is valid for all of `'py`, including detached and -suspended intervals. Unbinding copies that key into the resulting `Py`. -A borrowed handle needs only the pointer plus lifetime markers because its -checked construction binds it to the context's scope. On a 64-bit target, -both owning forms can remain two words; padding is target-dependent. - -The scope brand proves association and lifetime, not independent permission -to touch Python. A `Bound` or `Borrowed` can exist while its context is -suspended, but Python access still requires an available context. They cannot -create a context or expose a safe raw-object dereference on their own. - -Every safe `Py` operation that accesses the object, including binding, -downcasting, cloning a reference, conversion, and explicit destruction, must -first compare the handle's key with the context's key. Mismatch is a native -`InterpreterMismatch` error and must be detected before dereferencing the -object, even to inspect its type or refcount. This also rejects an expired -handle when a later interpreter occupies the same address. The active -context protects the matching interpreter for the whole operation. - -The binding signature is: +`Borrowed` stores only a pointer; its owner supplies `'a` and the context +supplies `'py`. `Bound` stores the pointer and a pointer to its entry's +release sink, which records the interpreter key. `Py` stores the pointer +and an `InterpreterKey`. Both owning forms are two words on 64-bit targets. +The scope brand proves association, not permission: handles can exist while +the context is suspended but cannot be used without an available context. + +#### Binding + +Every safe `Py` operation that touches the object (binding, downcasting, +cloning, conversion, destruction) first compares its key with the +context's, before dereferencing anything. Mismatch is a native +`InterpreterMismatch`. This also rejects expired handles whose address a +later interpreter reuses. ```rust impl Py { @@ -519,14 +426,8 @@ impl Py { } ``` -Scoped operations accept the same invariant `'py` on all handles and the -context, avoiding repeated dynamic identity checks. Leaf access also needs -no repeated attachment check: the scope protocol and transition enforcement -maintain that invariant until the next exclusive operation. To propagate a -native mismatch through a Python callback, explicitly raise an exception on the -current context; no error conversion implicitly consults ambient attachment. - -Moving among the forms is explicit: +Scoped handles share the context's invariant `'py`, so they need no +repeated checks. Moving between forms is explicit: ```rust let stored: Py = exception_type.unbind(); @@ -535,124 +436,81 @@ let current: Borrowed<'_, 'py, PyType> = stored.bind(ctx) let retained: Bound<'py, PyType> = current.to_owned(ctx); ``` -Neither owning handle implements `Clone`; `clone_ref(ctx)` and -`to_owned(ctx)` make each incref explicit. Raw borrowed and owned-pointer -constructors are private. Their glue proves non-nullness, interpreter identity, -liveness, and the C ownership contract before constructing a handle. Merely -having an attached thread state does not validate an arbitrary raw pointer. -Upcasts, unbinding, and ownership transfers preserve the key; generated -callback arguments and audited API results acquire the current scope's key. - -All Rust references into Python-owned memory, including byte slices, class -payloads, and module state, are bounded by both the owner's lifetime and the -guarded entry scope. A `Py` in static Rust storage must not yield a static -`&[u8]`, `&C`, or `&S`. A view requiring continuous attachment additionally -borrows the context, excluding exclusive operations for its entire lifetime. -A Rust-only `Send + Sync` payload or immutable bytes view may span detachment -only under an audited contract retaining its owner and entry protection. -This does not authorize Python-field access without a matching context. -Safe code cannot return Python-backed references from the higher-ranked entry. - -An attachment borrow is not a lock or a proof of exclusive object access. -Another thread can mutate a Python object, and a foreign wrapper can reenter -Python on the same state without detaching. Therefore neither the transition -gate nor `&ThreadContext` justifies a Rust slice into mutable container storage, -an unsynchronized `&Cell<_>` payload, or an exclusive reference to published -Python memory. Such views need separate ownership and synchronization proofs. -`Send + Sync` remains mandatory for shared class and module payloads. - -Owning handles implement consuming explicit destruction: +Owning handles do not implement `Clone`; `clone_ref(ctx)` and +`to_owned(ctx)` make increfs explicit. Raw-pointer constructors are private +and prove non-nullness, identity, liveness, and ownership. Upcasts, +unbinding, and transfers preserve the key. + +#### References into Python memory + +Rust references into Python-owned memory (byte slices, class payloads, +module state) are bounded by both the owner's lifetime and the entry scope. +A static `Py` cannot yield a `'static` `&[u8]`, `&C`, or `&S`, and the +entry cannot return such references. A view that needs continuous +attachment also borrows the context. + +Attachment is not a lock: other threads and foreign wrappers can mutate +Python objects. Neither the gate nor `&ThreadContext` justifies a slice into +mutable storage, an unsynchronized `&Cell<_>` payload, or `&mut` into +published memory. Shared class and module payloads must be `Send + Sync`. + +#### Dropping ```rust object.drop_with(ctx)?; ``` -`Bound::drop_with(&mut ctx)` uses the shared scope brand and returns `()`. -`Py::drop_with(&mut ctx)` returns `Result<(), InterpreterMismatch>`; on -mismatch it consumes the handle through ordinary queued `Drop` and reports -the native mismatch without touching the foreign object. A Python callback -maps that error with `err.raise(ctx)`. On a match, explicit destruction uses -the exclusive-call protocol to release the reference directly, and consumes -the ownership exactly once. As elsewhere, `ctx` that is already a mutable -reference is passed directly, without adding another `&mut`. - -Ordinary `Drop` never decrefs, drains releases, attaches, or switches states, -even when the current thread happens to be attached to the right interpreter. -A direct decref could invoke a finalizer which reenters Python or detaches -while a shared context remains reachable through scoped TLS. Actual-attachment -checks in `Drop` would not establish the exclusive borrow needed for that -execution. `Bound::drop` instead appends to its entry's release sink; `Py::drop` -submits to its interpreter's queue by key. Both transfer -ownership without reading the object's header or invoking Python. - -A sealed marker that guarantees an immortal singleton, such as a dedicated -`None` or boolean marker, may omit release and queue submission entirely. -This is a static guarantee that release is a no-op, not a detached read of -the object's refcount. A general integer marker does not establish it merely -because some small integers are immortal. Upcasting may discard the static -knowledge and use the ordinary queue path; no extra immortality bit needs -to survive in the handle. Interpreter association and binding checks remain -unchanged, including for old singleton handles after reinitialization. - -Explicit release and queue draining preserve the incoming Python exception. -Generated Rust deallocators contain panics and preserve exception state while -reporting an unraisable error. `Drop` must not unwind, including when Rust is -already unwinding. Detached destruction is supported behavior; allocation -failure or a closed/expired interpreter uses the abandonment policy below, -rather than a decref in the wrong execution context. - -`PyAny` is the erased object marker. More specific markers, such as `PyBytes` -and `PyType`, have the same transparent layout, so upcasts to `PyAny` do not -touch the refcount. There is intentionally no `&mut PyAny`: Python objects -have interior mutability and can be changed through aliases, especially in a -free-threaded build. - -Cyclic GC tracking is handled for modules and classes and described in their -respective sections. A tagged `Py` stored in pure Rust state may remain -opaque even if its interpreter expires. Publishing a handle as a traced -Python edge additionally requires association with the containing object's -interpreter, both at construction and every later mutation. A binding check -at eventual use is too late to protect the collector. - -#### Deferred reference release - -Each Rust entry has a private release sink with a small inline buffer. -`Bound::drop` appends through audited interior mutability in that sink, with -no allocation, TLS lookup, global lock, or Python call while capacity remains. -The entry owns the buffer; no queue node points into the dropped handle. -When the buffer fills, it transfers a batch to the interpreter's native -queue, or obtains Python-free overflow storage. It must never make room by -decrefing in `Drop`. If both options fail, retain every previously accepted -reference in the sink and abandon only the incoming, unaccepted reference -under the policy below. Acceptance includes insertion into an inline sink; -later allocation failure cannot revoke it. No mutable borrow of sink storage -or queue lock spans a callback. - -**Sink aliasing invariant:** no exclusive Rust reference to sink storage may -remain live across any operation that can drop a handle using that sink. -Pushes use audited interior mutability. Draining extracts a batch using raw -access or a completed short borrow before executing decrefs; the guard must -not retain `&mut` access to the sink across reentry, even if queue locking -would otherwise appear to serialize writes. Nested and unwinding drops must -be tested against this invariant. - -The sink remains valid through suspended contexts and Rust unwinding. Only -`Bound` handles reach it, through their direct pointer. There is no -thread-wide sink lookup: `Py::drop` always submits to the interpreter queue, -even inside an entry for the same interpreter. That costs a queue submission -instead of an inline push, which should be uncommon because stored handles -are mostly dropped at teardown; `drop_with` releases directly when prompt -release matters. A stale sink cannot survive entry exit. Entry exit -drains or transfers every buffered reference before retiring the sink. If -transfer fails, it drains the retained entries under private release -authorization and lifetime protection; it cannot abandon an accepted batch. -After external admission closes, authorized cleanup sinks use their private -cleanup route for overflow too, or retain accepted entries for local draining. -That route uses accepting cleanup storage or Python-free overflow allocation; -it must not recursively resubmit to the same full inline sink. - -CPython owns one native release queue per interpreter lifetime. The following -new private API is a required part of this design, not an existing C API: +`drop_with(&mut ctx)` releases directly under the exclusive-call protocol. +For `Py`, it returns `InterpreterMismatch` on mismatch after falling back to +an ordinary queued drop. + +Ordinary `Drop` never decrefs, attaches, or switches states, even on the +right interpreter: a finalizer could reenter Python while a shared context is +reachable through TLS. `Bound::drop` appends to its function entry's sink; +`Py::drop` submits to its interpreter's queue. Neither reads the object. +Sealed markers for immortal singletons (`None`, booleans) may skip release +entirely; a general integer marker may not. + +`Drop` must not unwind. Explicit release and draining preserve the incoming +exception. Generated deallocators report panics as unraisable, setting +aside any propagating exception while building the report so the caller's +exception is not replaced (debug builds abort otherwise). + +#### Markers and GC + +`PyAny` is the erased marker; specific markers like `PyBytes` share its +transparent layout, so upcasts are free. There is no `&mut PyAny`, since +Python objects can change through aliases. + +A `Py` in pure Rust state may stay opaque after its interpreter expires. +Publishing it as a traced GC edge requires association with the containing +object's interpreter at construction and every mutation. + +### Deferred reference release + +#### Entry sinks + +Each entry has a release sink with a small inline buffer, owned by the entry +guard. `Bound::drop` appends to it with no allocation, lock, or Python call. +When it fills, it transfers a batch to the interpreter queue or obtains +Python-free overflow storage, never decrefing in `Drop`. If both fail, only +the incoming reference is abandoned; accepted ones are kept. + +**Aliasing invariant:** no `&mut` to sink storage may be live across an +operation that can drop a handle into that sink. Pushes use interior +mutability, and draining extracts a batch before decrefing. + +There is no thread-wide sink lookup: `Py::drop` always uses the interpreter +queue, even inside a same-interpreter entry. Stored handles are mostly +dropped at teardown, and `drop_with` avoids the queue when promptness +matters. Exit drains or transfers every buffered reference before retiring +the sink, draining locally if transfer fails. After admission closes, +authorized cleanup sinks overflow through a private cleanup route. + +#### The release queue + +CPython owns one native release queue per interpreter lifetime, behind a new +private API: ```c typedef enum { @@ -667,163 +525,128 @@ _PyDeferredReleaseResult _PyObject_TryDeferDecRef( PyObject *owned_reference); ``` -The caller supplies exactly one owned reference associated with the key, or -the opaque pointer remaining from such ownership after the interpreter has -expired. Only `_Py_RELEASE_QUEUED` consumes that reference. The queue takes -over the existing reference without an incref; the producer must not also -decref it. Failure consumes nothing, sets no Python exception, and does not -inspect the object. Keys and pointers are private to the safe Rust wrapper; -the raw C API retains these ownership and association preconditions. - -Submission is callable from any native thread: detached, attached to another -interpreter, or after `Py_FinalizeEx`, while the CPython library remains -loaded. It must not read the object's header, change its refcount, invoke -Python, attach a thread state, or panic. Queue storage and diagnostic paths -must have an audited Python-free allocation/synchronization contract. A -short native lock or allocation may contend, but submission must not wait -for the target interpreter to execute, acquire its GIL, or finish finalizing. -Pooled queue nodes and batched draining amortize submission overhead. A -node owns its copied pointer; it must not refer to the Rust handle's former -stack or container location. - -A process-lifetime registry maps generation keys to live queue endpoints. -Registry synchronization survives runtime reinitialization. Lookup, admission, -and any notification that accesses interpreter or thread-state memory are -protected against interpreter deletion as one protocol. No unprotected -`PyInterpreterState *` escapes that protocol. Retirement removes the endpoint -before freeing interpreter memory; absent keys are expired and never alias a -new interpreter. In-flight producers protect only submission bookkeeping, -not an indefinitely retained Python handle. Notification must remain reliable -when the ordinary pending-call queue is full: an accepted release cannot -disappear merely because scheduling a wakeup failed. - -Use a dedicated release-work notification, such as an eval-breaker bit, -instead of requiring a slot in the bounded pending-call array. The protocol -must also wake a finalizing thread waiting on guards or producers when new -release work needs servicing or the last in-flight producer finishes, and -preserve the existing last-guard-release wakeup. An eval-breaker bit alone -cannot wake an indefinitely parked native waiter. Notification paths retain -endpoint protection until they finish accessing interpreter or thread-state -storage; producer completion and its wakeup must also be coordinated with -endpoint retirement. The wait predicate and wakeup protocol must prevent -lost wakeups when either new work or completion races the finalizing thread's -transition into its native wait. In particular, draining a producer's node -does not imply that its notification and submission bookkeeping have finished. - -Before removing a normal batch, the consumer acquires one finalization lease -for that batch and increments its attached state's protection count; -`ctx.drain_releases()` uses its entry's protection instead. It retains this -protection through decrefs, reentrant callbacks, and batch cleanup, including -any temporary detach inside a C deallocator. Shutdown accounts for these -active consumers as well as producers: an empty queue does not prove that a -previously removed batch has finished. This is one lease per batch, not per -reference. The final shutdown drain instead uses cleanup authorization. - -The consumer takes a batch out of the queue and drops its native locks before -decrefing any object. Each queued reference is released exactly once while -attached to its associated interpreter with permission to execute -deallocators. Until then the queued ownership is an external strong reference -for GC purposes; moving an edge out of a GC field must remove that field's -edge before publishing the release. The consumer preserves the incoming -exception and tolerates finalizers that reenter Rust or submit more releases. -It must not hold an incompatible Rust payload borrow across a decref. - -Draining requires the exclusive-call protocol, either through -`ctx.drain_releases()` or private release authorization between scopes. -CPython checkpoints defer draining while any context on the current OS -thread is available; being attached to the right interpreter is insufficient. -An explicit drain suspends its context for the whole batch and restores it -after all reentrant callbacks have finished. Eligible entry boundaries retain -the entry lease while servicing releases after the scope ends. - -Draining uses bounded batches during normal execution and also runs as part -of interpreter shutdown. It must not depend solely on another call into a -Rust module. Deferred release promises neither destruction before `Drop` returns -nor destruction on the submitting OS thread. Without target execution it can -retain resources until shutdown; native resources that need prompt release -should expose an explicit close operation with a context. - -Shutdown uses the following admission protocol: - -1. While open, the queue accepts references and can be drained normally. -2. The pre-finalization loop lets protected entries and normal consumers, - including detach/reattach and nested callbacks, finish while the - interpreter is intact. It continues servicing releases while waiting, - releasing its GIL and any locks needed by those entries. Entry-local sinks - and removed batches remain covered by their leases until drained or - transferred; an empty interpreter queue does not account for them alone. -3. Coordinate closing queue admission against detached producers with the - existing finalization-guard barrier. The loop's final completion check - includes queued releases and in-flight submissions, in addition to the - existing thread, pending-call, and guard conditions. A submission racing - that check either remains accounted for and causes another drain cycle, - or is rejected by closed admission. One implementation closes admission - in the same synchronized final check as guard admission; another may - close the queue earlier and finish all admitted submissions before that - check. No check-then-enqueue gap is permitted. Waiting and decrefing occur - after restarting the world and releasing incompatible locks. Integrate - this with `make_pre_finalization_calls`, rather than adding a drain after - it returns with the world stopped and its pending-call lock held. Once - guard admission closes, new normal entries and consumers cannot enter; - subsequent drains require cleanup authorization. -4. Authorized finalization callbacks still enqueue their ordinary drops into - a cleanup sink. After external admission closes, CPython accepts this - sink's releases only under the finalizing thread's private cleanup - authorization and matching key. Drain cleanup sinks and their reentrant - submissions to completion before tearing down object-supporting state; - this also covers drops produced by later module and type cleanup. - Explicit release still requires exclusive context access. New external - submissions receive `CLOSED`; after endpoint retirement they receive - `EXPIRED`. No accepted queue or sink entry survives interpreter teardown. - -An ordinary `atexit` drain or a check-then-enqueue sequence does not establish -this protocol. The final drain's reentrant callbacks remain under cleanup -authorization, and may not acquire a new normal entry lease or request -explicit detachment. Emptying one batch is not sufficient if its callbacks -created more cleanup releases. The any-thread API may route a matching call -from an authorized cleanup scope to that scope's sink; it must never infer -authorization merely from being attached to a finalizing interpreter. - -On `CLOSED`, `EXPIRED`, or `NO_MEMORY`, Rust `Drop` deliberately abandons the -unreleasable reference and records a Python-free, non-reentrant, -non-allocating, non-panicking diagnostic. It never retries by dereferencing -the pointer or decrefing in a different interpreter. This policy can leak -resources, but cannot make an expired object accessible through safe Rust. -The same rule applies to native shutdown and TLS destructors. Safe Rust can -also forget a handle, so correctness cannot rely on every destructor running. - -This queue guarantees release of accepted references before teardown, not -reclamation of every `Py` retained outside the interpreter. Releasing -those references during shutdown would require registered, revocable -ownership records; guaranteeing enqueue under allocation failure would -require reserved storage. Neither cost is imposed on every owning handle in -this design. The common scoped drop uses the inline sink; explicit destruction -can avoid queue traffic when prompt release matters. Deferred release changes -finalizer timing even on attached threads, and batching may retain memory -longer. Benchmark the inline path, overflow, and unbound `Py` submissions; -there is no claim that queuing every implicit drop is free. - -On platforms supporting `fork`, the registry, sinks, availability flag, -thread-state protection counts, and lease counts must participate in CPython's -before/after-fork protocol. Detached producers must be quiesced or -synchronized by that protocol; stopping attached Python threads alone is -insufficient. The child must reinitialize native synchronization, preserve -accepted releases for surviving interpreters, remove the leases held by -vanished threads, clear their states' protection counts before deleting those -states, and retire endpoints for interpreters removed after fork. The -surviving thread's leases can be recovered from its own states' protection -counts, because each protected entry or consumer holds one lease on its -state's interpreter; cleanup-authorized entries hold none, so fork under -cleanup authorization must be refused or accounted for separately. The -surviving thread's flag and guards remain valid. Fork preparation cannot wait -while holding a lock that a paused producer needs. The queue is not ready for -general use until these child and parent paths are defined and tested. - - -#### Exceptions and error handling - -Errors own their exception rather than marking a pending exception in an -ambient thread state: +Only `_Py_RELEASE_QUEUED` consumes the reference: the queue takes it over +without an incref, and the producer must not also decref it. Failure +consumes nothing, raises nothing, and does not inspect the object. + +Submission works from any native thread (detached, in another interpreter, +or after `Py_FinalizeEx`). It must not read the object, touch its refcount, +call Python, attach, or panic, and it must not wait for the target +interpreter. Allocation must be Python-free: `PyMem_RawMalloc` does not +qualify, because under tracemalloc its hook calls `PyGILState_Ensure`. C +`malloc` and a native mutex do. + +A process-lifetime registry maps keys to queue endpoints and survives +reinitialization. Lookup, admission, and notification are protected against +interpreter deletion as one protocol. Retirement removes the endpoint before +freeing the interpreter and waits for in-flight producers, so a missing key +means expired and never aliases a new interpreter. + +#### Notification + +Notification must not depend on the bounded pending-call array, and all +eight eval-breaker bits are taken. A producer sets a per-interpreter +`releases_pending` flag and, on the clear-to-set edge, the existing +calls-to-do bit. GIL builds also check the flag on GIL acquisition. Producers +attached to the target set their own eval breaker; others signal its threads +under the thread-list lock. + +Checkpoint drains are bounded by node count so producers cannot starve the +eval loop. A drain that leaves work, or defers because a context is +available, re-arms the flag. + +#### Waking the finalizer + +A finalizer parked waiting for guards and leases needs a native wakeup, and +whoever releases the last guard or lease must not touch the interpreter, +which may be freed at once. The wake word and a waiting-finalizer count +therefore live in process-global storage shared by all finalizers; wakeups +for other interpreters are harmless. Three events wake finalizers: a +producer setting `releases_pending`, release of the last Rust lease, and +`PyInterpreterGuard_Close` releasing the last C guard. + +A waker does nothing if the waiter count is zero; otherwise it increments the +word and unparks. The finalizer registers as a waiter first, then on each +iteration reads the word, services releases, checks the counts, and parks +expecting the word it read. With sequentially consistent atomics no wakeup is +lost. The loop should also run pending calls before parking, and scheduling a +pending call should wake finalizers; otherwise a guard holder waiting on a +pending call scheduled after the last `_Py_FinishPendingCalls` deadlocks (an +existing bug; see the appendix). + +#### Draining + +Draining requires the exclusive-call protocol, via `ctx.drain_releases()` or +private release authorization between scopes. Checkpoints defer draining +while any context on the thread is available. + +A consumer that finds work takes one lease and increments its state's +protection count for the batch, holding them through decrefs and reentrant +callbacks, so shutdown accounts for in-progress batches. It takes no lease +for an empty queue, since releasing one would wake the finalizer forever. The +consumer drops native locks before decrefing. Each reference is released +exactly once, attached to its own interpreter; until then it counts as an +external strong reference for GC, so moving an edge out of a GC field +removes the field's edge before submitting the release. + +Deferred release promises neither destruction before `Drop` returns nor on +the dropping thread. Resources needing prompt release should expose an +explicit close method. + +#### Shutdown + +1. **Open:** the queue accepts and drains normally. +2. **Pre-finalization:** the finalizer lets leased entries and consumers + finish while the interpreter is intact, servicing releases and releasing + its GIL and locks while it waits. +3. **Close admission:** queue admission closes with the existing + finalization-guard barrier. The final check covers queued releases and + in-flight submissions alongside threads, pending calls, and guards, with + no check-then-enqueue gap. This integrates with + `make_pre_finalization_calls` rather than running after it with the world + stopped. Afterwards, normal entries and consumers are refused. +4. **Cleanup drains:** authorized finalization callbacks drop into a cleanup + sink. External submissions get `CLOSED`, or `EXPIRED` after retirement. + Cleanup sinks drain to completion after module finalization, before + interpreter clear, and within it before the last collection, after + `_PyGC_Fini`, and at the final drain point, covering drops from + module/type cleanup, the last collection, `gc.garbage`, and teardown of + warnings state, `sysdict`, and `builtins`. +5. **Final drain point:** right after the interpreter empties `sysdict` and + `builtins`, before it releases them and clears its common constants. + After that, running any bytecode is unsafe (even `return None` loads a + common constant), so cleanup authorization ends: no entries, no + consumers, and the cleanup route closes. Later callbacks degrade safely: + deallocation drops payloads without a context, clear leaks, and functions + raise through the plain C API. Every reference accepted before this point + is released; none is accepted after. + +If the interpreter is cleared from another interpreter's thread state, no +drain can run and the queue is abandoned at retirement. + +#### Abandonment + +On `CLOSED`, `EXPIRED`, or `NO_MEMORY`, `Drop` abandons the reference and +records a Python-free, non-allocating, non-panicking diagnostic. It never +retries in another interpreter. This can leak but never exposes an expired +object. Since safe Rust can forget handles, correctness never depends on +destructors running. Reclaiming every retained `Py` would need revocable +ownership records on every handle (see the appendix). + +#### Fork + +The registry, sinks, availability flag, and protection and lease counts join +CPython's fork protocol. Detached producers must be quiesced, and fork +preparation must not wait while holding a lock they need. The child +reinitializes synchronization, keeps accepted releases for surviving +interpreters, clears vanished states' counts before deleting the states, and +retires endpoints for removed interpreters. If after-fork cleanup would +delete a state protected by a suspended entry on the forking thread, the +child fails fatally. `PyOS_AfterFork()` without `PyOS_BeforeFork()` cannot be +made safe. The queue is not ready for general use until both sides are +tested. + +### Exceptions ```rust #[must_use] @@ -834,19 +657,13 @@ pub struct PyErr { pub type PyResult = Result; ``` -`PyErr` has the ownership and interpreter association of `Py`. It can move -between Rust threads, but can be inspected or restored only through a context -for its associated interpreter. It is not tied to the thread state on which -the exception was created. Dropping or replacing it enqueues its reference; -recovering from an error in Rust therefore does not leave a hidden exception -pending on the thread state. No ambient `PyErrRaised` token or freshness -tracking is needed. +`PyErr` owns its exception, with `Py`'s ownership and interpreter +association, rather than marking one pending on the thread state. Dropping +it queues the reference, so recovering in Rust leaves no hidden pending +exception. -Built-in and custom exception types provide `raise(&mut ctx, message)` which -constructs and returns an owned `PyErr`. The name describes the intended -Python error; the exception is not left pending until the generated boundary -restores it. Exception construction may allocate and reenter, so it follows -the exclusive-call protocol: +Exception types provide `raise(&mut ctx, message)`, which returns an owned +`PyErr`; the exception is set only when the generated boundary restores it: ```rust fn checked_size(ctx: &mut ThreadContext<'_>, size: usize) -> PyResult { @@ -855,12 +672,9 @@ fn checked_size(ctx: &mut ThreadContext<'_>, size: usize) -> PyResult { } ``` -A C wrapper applies the function's complete error contract. Ambiguous numeric -sentinels also require inspecting the error indicator. Once failure is known, -it immediately takes ownership of the raised exception and clears the error -indicator before any unrelated Python operation or release processing. The -private capture happens while the wrapper still controls the exact originating -state, before resuming its context: +When a C call fails, its wrapper immediately takes the raised exception +(with `_PyErr_GetRaisedException`, a pointer transfer) while it still +controls the originating state: ```rust // Within the private C-call wrapper, with its exclusive borrow held: @@ -873,42 +687,36 @@ if ptr.is_null() { } ``` -Taking the raised exception is a pointer transfer, not a new allocation or -incref; `_PyErr_GetRaisedException` already provides the basic operation. -The private helper also records the interpreter key. A missing exception -where the C contract requires one becomes `SystemError` through authorized -reporting, never an unchecked null owning handle. Error creation or reporting -that itself fails must preserve an appropriate CPython exception such as -`MemoryError`; it cannot assume exception construction is infallible. - -At the generated boundary, keep a successful result or owned error alive -through conversion and cleanup, then commit the C result. `Ok` requires a -clear error indicator; otherwise release the result through authorized -cleanup and return an appropriate error sentinel. For `Err`, first check its -interpreter key without touching the exception pointer, then restore/steal its -reference into the current state and return the ABI's error sentinel. A -foreign error is queued under its original key and replaced with a local -`SystemError`. This catches even an error obtained indirectly from another -interpreter's callback. - -Restoring or replacing an ambient exception may decref the previous exception -and reenter, so it requires the exclusive-call protocol too. Finish cleanup -before the final restore where possible; any subsequent release processing -must save and restore that incoming exception. Custom exception types stored -as `Py` require checked binding before use. Built-in exception -accessors obtain an appropriately associated handle for the current context. -Native identity, entry, detachment, and interoperation errors never implicitly -consult ambient attachment; their explicit conversion requires an available -mutable context or private authorized boundary reporting. - -#### Concrete object abstractions - -Concrete types such as `PyBytes` and `PyModule` are transparent markers. APIs -operate on the marker or `Bound<'py, Marker>`, so type-specific methods require -a checked or statically known marker. The initial set grows with module needs. - -`PyBytes::new_with` zeroes an unpublished payload before lending it as -`&mut [u8]`. `new_with_uninit` avoids zeroing but is unsafe: +Ambiguous sentinels also check the error indicator. A missing exception +becomes `SystemError`. Failures while creating or reporting an error must +leave an appropriate exception such as `MemoryError`. + +At the boundary, `Ok` requires a clear error indicator. `Err` has its key +checked first; a foreign-interpreter error is queued under its own key and +replaced with `SystemError`. Restoring an exception may decref the previous +one, so it uses the exclusive-call protocol. + +The built-in exception marker trait is sealed because its type pointer goes +straight to C. Custom exception types stored as `Py` need checked +binding. Converting a native error (identity, entry, detach, interop) into a +`PyErr` requires an explicit mutable context; nothing consults ambient +attachment. + +### Concrete objects and conversions + +Concrete types such as `PyBytes` and `PyModule` are transparent markers with +type-specific methods on `Bound<'py, Marker>`. The set grows with module +needs. `Bound` can add values and types, and +`PyType::new_exception` creates a heap exception type. + +`PyType::new_exception` shows why a C function's nominal result type is not +proof: `PyErr_NewException` calls `type(name, bases, dict)`, whose metaclass +can return any object. The wrapper checks the result is a type. The helper +that types a C result is `unsafe`, and Python-choosable results use a checked +downcast. + +`PyBytes::new_with` lends a zeroed unpublished buffer; `new_with_uninit` +skips zeroing: ```rust let bytes = PyBytes::new_with(ctx, output_len, |output| { @@ -925,28 +733,11 @@ let bytes_uninit = unsafe { }?; ``` -The unsafe form lends `&mut [MaybeUninit]` and requires every byte to be -initialized before returning `Ok(())`. Error and panic paths may leave it -partially initialized: private cleanup discards the unpublished object -without reading those bytes or exposing them to Python. Initialization runs -under the exclusive-call protocol, with no context passed to the closure; -restoration and cleanup must also work when it panics. Even for zero length, -an implementation must not lend mutable access to a shared bytes singleton. -The closure can still call a foreign Python wrapper or run arbitrary -destructors. Suspension permits that reentry while making the outer context -inaccessible; exclusive ownership of the unpublished bytes separately -protects the mutable slice. - -The other initial abstractions are deliberately small. `Bound` can -add converted values and type objects, while `PyType::new_exception` creates -a heap exception type. `PyType` defaults to an untyped class marker for these -general operations; the class-definition section specializes it as -`PyType` for types whose instances contain a Rust payload `C`. - - -#### Rust/Python conversions +On error or panic the unpublished object is discarded unread. The closure +runs with the context suspended. Zero-length requests must not lend the +shared empty-bytes singleton. -Conversions use one trait per direction: +#### Conversions ```rust pub trait FromPyObject<'a, 'py>: Sized { @@ -964,26 +755,15 @@ pub trait IntoPyObject<'py> { } ``` -Both require exclusive context access and are fallible because they may -allocate or invoke Python protocols. Calling a trait method does not itself -suspend the context: its implementation receives an available capability and -only private C wrappers suspend it around actual calls. Their shared invariant -`'py` lifetime prevents scoped results from escaping or being mixed across -contexts. `FromPyObject` preserves the owner's `'a` -lifetime, allowing zero-refcount borrowed arguments; retaining one requires -`to_owned(ctx)`. Implementations returning a Rust reference with lifetime -`'view` into Python storage require both `'a: 'view` and `'py: 'view`; an -owner lifetime alone is insufficient. An implementation accepting `Py` -first performs checked binding and maps mismatch using the supplied context. -The trait does not carry the short borrow lifetime of `ctx`, so it cannot -return a view requiring continuous attachment. Any such future accessor must -borrow the context for the returned view's lifetime and separately prove its -aliasing and concurrency requirements. - -Exact, published `PyBytes` has a simpler contract: its initialized byte -storage is immutable and can be read while detached. Its accessor is a leaf, -and bounds the slice by the object owner and interpreter entry, without tying -it to the short context borrow: +Both take exclusive access and are fallible. Implementations receive an +available context. `FromPyObject` keeps the owner lifetime `'a`, so borrowed +arguments cost no refcount; a reference `'view` into Python storage requires +`'a: 'view` and `'py: 'view`. The trait cannot return views that need +continuous attachment. + +Exact, published `PyBytes` storage is immutable and safe to read while +detached, so its accessor is a leaf bounded by owner and entry rather than +by the context borrow: ```rust impl<'a, 'py> Borrowed<'a, 'py, PyBytes> { @@ -997,43 +777,31 @@ impl<'a, 'py> Borrowed<'a, 'py, PyBytes> { } ``` -The checked marker must establish exact immutable bytes storage, not merely -accept an arbitrary buffer exporter. The owner keeps the allocation live and -the entry lease protects it through detachment and interpreter shutdown; -neither protection can end while the slice is in use. The accessor performs -no incref and introduces no input copy: +It does not incref or copy: ```rust let input = data.as_bytes(ctx); let output = ctx.detach_if_possible(|| compress_without_python(input)); ``` -A slice into writable or externally mutable buffer storage cannot use this -proof. Such exporters require the separate buffer design in the appendix. -This example's CPU work must also be valid while attached; calls requiring -actual detachment use strict `detach` and handle its native refusal. +This proof does not extend to arbitrary buffer exporters (see the +appendix). -Generic implementations compose. `Option` maps `None`, borrowed handles can -be promoted with an incref, and an existing `Bound` can be returned without -refcount traffic: +Generic implementations compose: ```rust let value: Option = FromPyObject::extract(ctx, argument)?; let object: Bound<'py, PyAny> = value.into_pyobject(ctx)?; ``` -Generated callbacks accept a convertible value or `PyResult`. Returning `T` means the Rust function -body cannot itself report a Python exception, although converting `T` into a -Python object may still fail. Returning `PyResult` lets the function use `?` -to propagate `PyErr`; only an `Ok(T)` value is converted. - +Callbacks may return `T` or `PyResult`; only `PyResult` lets the body +raise, though converting `T` can still fail. -#### Module definition +### Modules -Modules use PEP 793's `PyModExport` and `PySlot` representation rather than a -legacy `PyModuleDef` initializer. A declarative `export_module!` macro builds -the static method table, ABI declaration, slot array, lifecycle callbacks, -and correctly named export symbol: +Modules use PEP 793's `PyModExport` and `PySlot`. `export_module!` builds the +method table, ABI declaration, slot array, lifecycle callbacks, and export +symbol: ```rust export_module! { @@ -1045,12 +813,13 @@ export_module! { } ``` -The declarative macro expands to inspectable static Rust items. -`#[pyfunction]` emits `ModuleMethodDef`; `export_module!` accepts only -methods carrying its declared `S`, so state mismatches are Rust type errors. +It binds `name` and `doc` to `const` items in safe code before the `unsafe` +slot construction. The slot, method, and ABI table types have private fields +and `unsafe` constructors that only the macro calls. `#[pyfunction]` emits a +`ModuleMethodDef`, and `export_module!` accepts only methods with its +state `S`. -Generated entry points represent a module whose definition is known with a -typed borrowed wrapper: +#### Definition identity ```rust pub struct ModuleRef<'a, 'py, S> { @@ -1059,14 +828,19 @@ pub struct ModuleRef<'a, 'py, S> { } ``` -Only generated `ModuleDef` glue constructs `ModuleRef` and proves the -state type and interpreter association. It is invariant in `'py` through its -borrowed module handle. Static definitions describe ABI/layout and contain no -interpreter-owned Python objects; each runtime module instance has its own -state, heap types, and custom exceptions. Instances in the same interpreter -also remain distinct: an interpreter key does not identify a module instance. +Only generated `ModuleDef` glue constructs a `ModuleRef`, ensuring a valid +state type and interpreter. Static definitions hold no interpreter-owned +objects; each module instance has its own state, types, and exceptions. + +The module token is the static `ModuleDef`'s address, so definitions must +be distinct statics. A private `UnsafeCell` field makes const promotion a +compile error and prevents merging identical statics. `ModuleDef`, +`ModuleRef`, and `ModuleMethodDef` are also invariant in `S` (via +`fn(S) -> S`), per the [safety rules](#safety-rules). -Modules always have a typed, per-module state, even if it is empty: +#### Module state + +Every module has typed state, even if empty: ```rust pub trait ModuleState: Send + Sync + 'static + Sized { @@ -1105,11 +879,9 @@ impl ModuleState for Base64State { } ``` -`Send + Sync` supports concurrent access in free-threaded builds. Traced -Python fields use `GcSlot`, which owns an optional Python reference and -has a private interpreter key fixed at construction. GC clearing permanently -closes a slot to replacement, so reentrant code cannot repopulate an already -cleared edge while later clear callbacks are no-ops: +Traced fields use `GcSlot`, which owns an optional reference with a key +fixed at construction. GC clear permanently closes a slot, so reentrant code +cannot repopulate it: ```rust impl GcSlot { @@ -1133,79 +905,65 @@ pub struct GcSlotReplaceError<'py, T> { } ``` -`empty` is a metadata-only leaf. `get` and `replace` conservatively require -exclusive context access because their synchronization may need to park the -thread; any suspension occurs before acquiring slot protection. Both check -the slot's key before accessing its object pointer. -`replace` also binds the incoming value to the same scope through its type. -Its native `GcSlotError` distinguishes interpreter mismatch from a cleared -slot. Either error leaves the slot unchanged and returns the supplied value -inside `GcSlotReplaceError`, preserving ownership without allocation, incref, -or enqueue. The caller can recover it, or drop the error to use the ordinary -release policy. `get` returns `None` for a cleared slot after -checking identity. On success `replace` returns ownership of the removed -reference, so its decref occurs after releasing internal synchronization. -`get` acquires its returned strong reference while the slot contents are -protected, paying an incref to keep concurrent replacement safe. No API -exposes a mutable reference to the stored handle or lets safe code change a -published slot's interpreter association or reopen a cleared slot. - -`#[derive(ModuleStateGc)]` emits the associated policy. A sealed field trait -recurses through `GcSlot` and explicitly supported containers whose safe -mutations preserve that association. Before publishing state, `validate` -checks every traced slot against the containing module's key, including -empty slots. Bare `Py`, `Option>`, arbitrary `Mutex`/`Arc` wrappers, -and containers exposing unchecked replacement do not automatically qualify -as derived GC fields. Rust-only state emits `ENABLED = false` and installs no -payload GC callbacks. Manual policies are unsafe and must enforce the same -publication and mutation rules, correct edge multiplicity, pointer liveness -during visits, partial-clear validity, and nonblocking traversal. - -`GcSlot` clearing uses shared access and synchronized removal, never a Rust -`&mut S` across a decref. Its private implementation must let traversal visit -a live, correctly accounted reference throughout each `visitproc` call even -when another thread could otherwise mutate or clear the slot. The initial -implementation uses the following exclusion contract: - -- Published slot access and mutation require attachment. Waiters park - outside slot protection; a holder never parks, reaches a safepoint, runs - callbacks, or releases attachment while protected. This includes failure - and cleanup paths. Slot protection cannot be borrowed by author code. -- Normal GC traversal, `gc.get_referents`, and `gc.get_referrers` have GIL or - stop-the-world exclusion. Stopping attached mutators therefore cannot - leave a partially modified slot. `tp_clear` runs after the free-threaded - collector restarts the world and uses the synchronized mutation path. -- Debug `PyObject_GC_Track` can invoke traversal without stopping the world. - Generated factories cover that case by fully initializing and retaining - exclusive access to the unpublished object until tracking returns. - -Under this full exclusion and publication proof, traversal can load the -slot pointer without taking its lock. Atomic loads alone would not establish -the same reference-lifetime guarantee. Traversal must not block, run Python, -expose an unsynchronized `&Py`, or retain an incompatible exclusive Rust -borrow during a visit. Type/module lookups in traversal use the appropriate -`_DuringGC` accessors, which do not create owned references or Python errors. -Their native failure results must still be handled. - -External C callers invoking `tp_traverse` directly must provide equivalent -exclusion and valid, initialized inputs; arbitrary concurrent traversal of -mutable payloads is outside this unsafe FFI contract. New in-tree traversal -callers must be audited against the same two cases. References removed by clear -are released only after the slot is empty and closed and its internal -protection has been exited; clearing an already closed slot is a no-op. -Ordinary replacement with `None` merely empties a slot and does not close it. -Reentrant traversal -therefore sees valid shared state. Any supported container needs the same -proof before a sealed implementation is added. - -CPython allocates `StateStorage`, an interpreter key, atomic phase, and -`MaybeUninit`, before Rust constructs `S`. Generated glue initializes the -key from the current context. The macro rejects types whose alignment exceeds -the allocator's documented guarantee and checks all size arithmetic. -Repeated, reentrant, and concurrent exec calls require these explicit phases: +`get` and `replace` take exclusive access because they may park, and check +the key before touching the pointer. `get` increfs under protection and +returns `None` if cleared. `replace` returns the old reference so it is +released outside the lock; on mismatch or a closed slot, it returns the +incoming value unchanged inside the error. Replacing with `None` empties +without closing. Nothing exposes `&mut` to the stored handle. + +#### Deriving GC policies + +`#[derive(ModuleStateGc)]` and `#[derive(PyClassGc)]` classify fields by +their actual type through a sealed trait, since an alias can hide the +spelling: + +- `GcSlot` is traced. +- `Option`, `Box`, `Vec`, arrays, and boxed slices of slots are traced slot by + slot; none can change through `&`. Spelling one directly is a macro error + asking for direct fields. +- `Arc>` and `Rc>` are compile errors, even behind an + alias, because shared ownership would double-report edges. The error fires + after monomorphization. +- Anything else is untraced; a slot hidden in another wrapper leaks its + cycles, which is safe. + +`ENABLED` comes from `core::mem::needs_drop` on the field types, because no +spelling proves a field holds no slot (even `u64` can be shadowed). +`GcSlot` has drop glue, so callbacks are omitted only when no field does. +Slots in `ManuallyDrop`, `MaybeUninit`, or unions escape the test but are +never released either, so the effect is a leak. Manual policies are `unsafe` +and must uphold the same rules. + +#### GC slot synchronization + +Clear uses synchronized removal through `&S`, never `&mut S` across a +decref. Traversal reads slots without locking, relying on this exclusion: + +- Slot access requires attachment. A holder never parks, runs callbacks, or + detaches while protected. +- Traversal, `gc.get_referents`, and `gc.get_referrers` run under the GIL or + stop-the-world. `tp_clear` runs after the world restarts and uses the + synchronized path. +- Debug `PyObject_GC_Track` can traverse without stopping the world, so + factories keep the object exclusive until tracking returns. + +Object traversal must not block, run Python, or hold an exclusive borrow during a +visit; lookups use the `_DuringGC` accessors. Clear releases references +only after the slot is closed and unlocked. External callers of `tp_traverse` +must provide the same exclusion. + +#### State lifecycle + +CPython creates the module and binds its functions before allocating state; +`PyModule_Exec` then allocates zeroed `StateStorage` (key, atomic phase, +`MaybeUninit`). An unexecuted module is reachable, from +`importlib.util.module_from_spec` or `sys.modules` mid-import, so every +callback handles missing storage. The macro rejects over-aligned types. | Operation | Allowed source phase | Published phase and access | | --- | --- | --- | +| Any callback before exec | No storage | Functions and methods raise `SystemError("module is not initialized")`; traverse, clear, and free do nothing. | | Claim exec | `Uninitialized` | `Initializing`; any other source raises `SystemError`. | | Finish `S::new` | `Initializing` | Validate traced associations before publication; on failure drop the unpublished value and publish `FailedWithoutValue`; otherwise write `S` and publish `Executing`. | | Finish exec | `Executing` | `Ready` or `FailedWithValue`; `S` remains live. | @@ -1215,8 +973,6 @@ Repeated, reentrant, and concurrent exec calls require these explicit phases: | Free with value | `Executing`, `Ready`, `FailedWithValue`, `Clearing`, `Cleared`, or `ClearFailed` | Enter `Dropping`, drop once, then publish `Dropped`. | | Free without value | `Uninitialized` or `FailedWithoutValue` | Publish `Dropped` without calling `drop_in_place`. | -The exec hook has the typed signature: - ```rust type ModuleExec = for<'a, 'py> fn( &mut ThreadContext<'py>, @@ -1225,45 +981,24 @@ type ModuleExec = for<'a, 'py> fn( ) -> PyResult<()>; ``` -Non-panicking guards apply failure phases during unwinding. Stores that -publish an initialized value or completed transition use release ordering; -dependent reads use acquire ordering. - -`Ready` is an intentional readiness boundary for ordinary state access. -`S` already exists in `Executing`, but the exec hook may still be populating -its logical state. Permitting exec-time callbacks to read it is a possible -API extension, provided those methods handle incomplete setup explicitly; -Rust initialization alone does not require or forbid that policy. - -Unreachability alone does not justify an exclusive Rust payload borrow: -decref may reenter traversal and free-threaded access needs synchronization. -Derived clear removes edges through their shared `GcSlot` interface before -releasing them in the containing interpreter. Traversal retains its restricted -visitor capability; it cannot acquire a general context, drain a release -queue, or bind arbitrary `Py` handles. Identity validation has already -occurred at publication and mutation, rather than during GC. - -Free never retries a panicking destructor. Non-panicking guards still publish -`Dropped`; Rust drops `S` but CPython retains responsibility for freeing the -module-state allocation. - -Generated modules declare `Py_mod_gil = Py_MOD_GIL_NOT_USED`, the internal -exact-version ABI, the GIL/free-threaded build mode, and -`Py_mod_multiple_interpreters = Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`. -These declarations require an audit of native dependencies and mutable -process-global state as well as the Rust wrapper. Process-global Rust caches -may contain only Rust data or opaque, tagged handles checked before use; -interpreter-owned runtime types and exceptions belong in module state. The -module flags advertise support but do not substitute for the identity and -lifetime checks, even when the importer disables extension checks. - - -#### Function definition - -Module-scoped, Python-callable Rust functions remain ordinary Rust functions with two -explicit leading parameters: the thread context and the module state. -Python-visible arguments follow them. This interim example accepts exact bytes -pending a buffer API design: +Publishing stores use release ordering and reads use acquire. Guards apply +failure phases on unwind, and a panicking destructor is never retried. +Callbacks see state only once `Ready`, since exec may still be populating +it. + +#### Support declarations + +Generated modules declare `Py_MOD_GIL_NOT_USED`, +`Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`, the internal exact-version ABI, and +the build mode. That requires auditing native dependencies and global state. +Process-global Rust caches may hold only Rust data or checked opaque +handles. The identity and lifetime checks apply even when import checks are +disabled. + +### Functions + +Python-callable, module-scoped functions are ordinary Rust functions whose first two +parameters are the thread context and module state: ```rust #[pyfunction(signature = (data, /))] @@ -1276,29 +1011,21 @@ fn standard_b64encode<'a, 'py>( } ``` -The macro identifies context and state by position, then emits compiler-checked -uses of their declared types. The state determines `ModuleMethodDef`. -Functions that create objects, convert generic values, report Python errors, -or otherwise use exclusive operations take `&mut ThreadContext`. A function -whose body uses only leaf operations may take a shared borrow. Generated -argument and result conversion still use exclusive access before and after -that body's borrow. State and payload references remain shared in either case. - -`#[pyfunction]` preserves the Rust function body and emits its typed definition, -argument specification, and `METH_FASTCALL | METH_KEYWORDS` trampoline. The -trampoline enters panic containment and `with_current`, validates scoped C -inputs, recovers ready state, binds arguments, invokes Rust, and converts the -result to C before `'py` ends. - -The C input is untyped, so private glue validates it against the selected -`ModuleDef` before constructing a callback-scoped `ModuleRef`. Methods -use the same design with `METH_METHOD`. - -Panic containment covers entry bookkeeping, parsing, user code, conversion, -and cleanup. The private entry guard stays alive during panic payload -handling and error reporting; catching outside `with_current` alone would -release lifetime protection too soon. A pointer-returning boundary is -conceptually: +A body using only leaf operations may take `&ThreadContext`. `#[pyfunction]` +emits a `METH_FASTCALL | METH_KEYWORDS` trampoline that enters panic +containment and `with_current`, validates the module against its +`ModuleDef` and checks it has executed, binds arguments, calls the +function, and converts the result before `'py` ends. Methods use +`METH_METHOD`. + +Argument binding, defaults, and the call live in a generated safe function, +so author code never compiles under `unsafe`. The macros reject `unsafe`, +`async`, `const`, `extern`, and generic functions. + +#### Panic containment + +The entry guard must outlive panic handling, so the catch sits inside +`with_current`: ```rust // SAFETY: callers must obey PyCFunctionFastWithKeywords, provide live @@ -1307,24 +1034,24 @@ unsafe extern "C" fn trampoline( /* existing PyCFunctionFastWithKeywords arguments */ ) -> *mut ffi::PyObject { let boundary = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // SAFETY: callback inputs satisfy entry's attached-state contract. - let entered = unsafe { - with_current(|ctx| { - let body = std::panic::catch_unwind( - std::panic::AssertUnwindSafe(|| { - let value = call_rust_function(ctx); - convert_callback_result(ctx, value) - }), - ); - let result = match body { - Ok(result) => result, - Err(payload) => Err(panic_to_owned_error(ctx, payload)), - }; - // The context and entry guard still protect cleanup and the - // final transfer of an owned result/error to the C caller. - finish_callback(ctx, result) - }) + // Author code runs in this safe closure, outside any unsafe block. + let callback = |ctx: &mut ThreadContext<'_>| { + let body = std::panic::catch_unwind( + std::panic::AssertUnwindSafe(|| { + let value = call_rust_function(ctx); + convert_callback_result(ctx, value) + }), + ); + let result = match body { + Ok(result) => result, + Err(payload) => Err(panic_to_owned_error(ctx, payload)), + }; + // The context and entry guard still protect cleanup and the + // final transfer of an owned result/error to the C caller. + finish_callback(ctx, result) }; + // SAFETY: callback inputs satisfy entry's attached-state contract. + let entered = unsafe { with_current(callback) }; match entered { Ok(ptr) => ptr, // SAFETY: this helper establishes private reporting authorization @@ -1341,55 +1068,30 @@ unsafe extern "C" fn trampoline( } ``` -`panic_to_owned_error` disposes of the payload while holding exclusive context -access and suspending it around arbitrary payload destructor code. Because a -`panic_any` payload destructor may itself panic, disposal has a second catch; -a second panic aborts. The helper then constructs a fixed-message `SystemError` -through the ordinary error path, with the context available again. A fixed -message does not make reporting allocation-free or callback-free: exception -creation can allocate, and replacing an exception can run finalizers. - -Mandatory entry, restoration, and lifecycle guards must not unwind. -A panic already in flight restores attachment before the inner catch receives -it. The outer catch is a last-resort fatal boundary, not the normal path for -reporting author panics. Cleanup drain failures and inability to maintain the -CPython error contract cannot escape the non-unwinding C ABI. - -Not every callback ABI can report a normal Python exception, so generated glue -uses a callback-specific policy: +`panic_to_owned_error` drops the payload with the context suspended, under a +second catch (a second panic aborts), then builds a fixed-message +`SystemError`. Entry, restoration, and lifecycle guards never unwind; the +outer catch is a last-resort abort. | Callback kind | Panic policy | | --- | --- | | Function, method, getter, setter, and constructor | Set a fixed `SystemError` and return the ABI's `NULL` or `-1` error sentinel. | | Module create | Set a fixed `SystemError` and return `NULL`. | | Module exec | Commit the appropriate failed state phase, set a fixed `SystemError`, and return `-1`. | -| Module or instance clear | Finish the lifecycle transition, set `SystemError`, and return failure; CPython reports it as unraisable where required. | +| Module or instance clear | Finish the lifecycle transition and set `SystemError`, which CPython reports as unraisable. Instance clear returns failure. Module clear returns 0, because CPython skips clearing the module dict when `m_clear` fails. | | Traverse | Preserve incoming exception state and propagate the visitor's exact nonzero return and any error it sets. A Rust panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | -| `tp_dealloc` and module free | Run mandatory non-panicking cleanup guards first, then report the panic as unraisable when an attached state is available. Abort if cleanup itself cannot be completed safely. | - -Before `drop_in_place`, deallocation commits the destroyed phase and -installs guards for the Python allocation and heap-type reference. A panic -therefore cannot cause a second Rust drop or skip mandatory cleanup. Module -state uses the same pattern. Generated lifecycle glue holds exclusive context -access and suspends the context around arbitrary payload/state destructors -and any C cleanup that may reenter. These destructors receive no context, so -they cannot be responsible for calling `ctx.interop` themselves. Restore the -exact state and resume the context before reporting a panic. The entry's -release sink, lifetime protection, and state protection remain live -throughout this cleanup. - -During finalization, the same suspension preserves cleanup-phase restrictions; -it does not authorize unrestricted foreign interoperation. Only transitions -covered by the private cleanup authorization may proceed, with exact-state -restoration and protection through the full synchronous call. This applies -to panic payload destruction as well as class and module destructors. +| `tp_dealloc` and module free | Run mandatory non-panicking cleanup guards first, then report the panic as unraisable when an attached state is available. Set aside any pending exception while building the report and restore it afterwards. Abort if cleanup itself cannot be completed safely. | +Before dropping a payload or module state, deallocation commits the +destroyed phase and installs cleanup guards, so a panic cannot cause a +double drop or skip cleanup. Destructors run with the context suspended and +receive no context. During finalization, suspension keeps cleanup +restrictions in force. -#### Class definition and method definition +### Classes -A Rust payload type `C` identifies a class layout and static definition, its -module-state type, and its GC policy. Each module instance creates its own -runtime Python type from that definition: +A payload type `C` identifies a class's layout, static definition, module +state, and GC policy. Each module instance creates its own runtime type: ```rust pub trait PyClass: Send + Sync + 'static + Sized { @@ -1425,71 +1127,52 @@ impl PyClass for Encoder { static ENCODER_CLASS: ClassDef = ClassDef::new(c"example.Encoder") - .methods(&ENCODER_METHODS) - .getsets(&ENCODER_GETSETS); + .methods(Encoder::PY_METHODS) + .getsets(Encoder::PY_GETSETS); ``` -This prevents safe code from combining an unrelated payload, module state, or -definition. `#[derive(PyClassGc)]` uses the same sealed field rules as module -state. `ENABLED` controls payload-edge handling; every generated instance of -a heap type still owns an implicit edge to its actual type, which may in turn -own its defining module. Generated traversal reports that edge even for a -Rust-only payload such as `Encoder`. Manual payload policies are unsafe -because they must validate every edge's association, report it with the -correct multiplicity, clear it safely, and obey the class's synchronization -rules. The common glue handles the implicit type edge separately. - -`PyType` and `PyInstance` are transparent -markers over this identity. The default represents an ordinary untyped Python -type object. - -`ENCODER_CLASS` creates `Bound>`; a traced module-state field -stores it in `GcSlot>`. Upcasts to untyped `PyType` or `PyAny` -do not touch the refcount or change interpreter association. Downcasts first -establish the correct context and then validate the class token. Generated -methods, getsets, and constructors all carry `C`; method state is fixed to -`C::State`, but its value comes from the actual defining module instance. - -`ClassDef` computes the payload layout with checked arithmetic and installs -lifecycle slots. Types whose alignment exceeds the object allocator's -documented guarantee are rejected before allocation. The allocation records -initialization, clear, and drop phases so callbacks never read uninitialized -or destroyed `C`. - -Generated instance allocation uses `_PyType_AllocNoTrack`, writes valid Rust -phase metadata and the payload, and calls `PyObject_GC_Track` only after -traversal can safely read them. `PyType_GenericAlloc` already tracks GC -objects and therefore does not implement this sequence. Keep the instance -unpublished until tracking and its possible debug traversal finish; zeroed -allocation bytes alone do not make arbitrary Rust values initialized. - -`ClassDef::create(ctx, ModuleRef)` combines static slots with the -runtime module pointer in the same invariant scope and returns -`Bound>`. It verifies the unique `C::definition()`; a second static -definition requires a Rust newtype. The definition address is also stored as -`Py_tp_token`. It proves layout identity, not interpreter identity or a unique -runtime type: several module instances, including in different interpreters, -can have types with that token. No process-global Python type cache is used. - -`type_object.new_instance(ctx, payload)` validates the type token and every -payload GC slot's interpreter association before publishing an instance, -then returns `Bound>`. Failure drops the unpublished payload -using the ordinary release policy, which is safe even if it contains foreign -handles. Generated methods recover the defining module through the runtime -type's module association (`METH_METHOD` where applicable), validate its state -definition and interpreter key, and cap payload/state borrows by both owner -and entry lifetimes. - -Rust structs in classes must be `Send + Sync + 'static`. Python methods receive only `&self`, -never `&mut self`, because aliases can call the same object concurrently in a -free-threaded interpreter. Mutable payload state therefore requires interior -mutability. The appendix describes a `PyLock` prototype with an exclusive -context loan and guarded detachment. Its reentry, panic-recovery, and output -buffer policies remain open; an ordinary `Mutex` must not be presented as -the general solution until those rules are settled. - - -Python-owning fields add payload edges to the generated GC slots: +`#[derive(PyClassGc)]` uses the [module-state field rules](#deriving-gc-policies). +Every instance also owns an edge to its heap type, which generated traversal +reports even when `ENABLED` is false. + +#### Class identity + +The class token is the address `definition()` returns, and downcasts trust +it. If two classes returned identical promoted constants, the compiler could +merge them and one class's payload would be read with the other's layout, so +`ClassDef` has the same private `UnsafeCell` field as `ModuleDef`. The class +marker trait is sealed. Generating the static through an `unsafe` or sealed +trait, as `export_module!` does, would remove the remaining reliance on a +safe method. + +`ClassDef`, `PyType`, `PyInstance`, the generated method tables, and +`GcSlot` are invariant in their parameter. Otherwise safe code could +coerce a `Payload` type object to `Payload`, call +`new_instance`, and downcast back successfully. Handles may be covariant in +their marker, because every parameterized marker is invariant. + +`PyType` and `PyInstance` are transparent markers. +Upcasts to `PyType` or `PyAny` are free; downcasts check the context, then +the class token. + +#### Types and instances + +`ClassDef::create(ctx, ModuleRef)` returns +`Bound>`. Each `C` has exactly one definition; a second needs a +Rust newtype. The definition address is also the `Py_tp_token`, which +proves layout, not interpreter identity: several module instances can have +types with the same token. There is no global type cache. + +`type_object.new_instance(ctx, payload)` validates the token and every +payload slot's key before publishing. It allocates with +`_PyType_AllocNoTrack` (`PyType_GenericAlloc` tracks too early), writes the +payload and phase metadata, then calls `PyObject_GC_Track`. On failure or +unwind, the unpublished payload is dropped with the context suspended. + +Methods recover the defining module through `METH_METHOD`, validate its +state and key, and bound borrows by owner and entry. They receive `&self`, +never `&mut self`, since methods can be called concurrently on free-threaded +builds; mutable state needs interior mutability. ```rust #[derive(PyClassGc)] @@ -1499,9 +1182,8 @@ struct Node { } ``` -Generated heap-type instances, including both `Encoder` and `Node`, set -`Py_TPFLAGS_HAVE_GC` and install traversal for the actual heap-type edge; -`Node` additionally traverses and clears its payload edge. Lifecycle rules are: +All generated instances set `Py_TPFLAGS_HAVE_GC` and traverse their type +edge; `Node` also traverses and clears its payload edge: | Operation | Invariant | | --- | --- | @@ -1510,29 +1192,23 @@ Generated heap-type instances, including both `Encoder` and `Node`, set | Clear | Remove payload edges through synchronized `GcSlot` access using `&C`; release removed references after leaving slot protection; later clears are no-ops. The instance retains its type until deallocation. | | Deallocate | Untrack first. Contain any required clear independently; then commit dropping, drop `C` once, run allocation/type guards, and report the first panic. | -Types are immutable, final, and non-instantiable unless they define `tp_new`. -Finality preserves the `METH_METHOD` defining-class link used to recover typed -module state. Rust factories may always use `new_instance`. +Types are immutable, final (preserving the `METH_METHOD` defining-class +link), and non-instantiable unless they define `tp_new`. -`#[pymethods]` processes four restricted kinds of entries: +#### Methods -- `#[pyfunction]` creates a normal instance method, a `PyCMethod` trampoline, - and a `ClassMethodDef` whose state parameter is `&C::State`. -- `#[getter]` creates a read-only `ClassGetSetDef`. -- `#[setter]` creates the setter half of a `ClassGetSetDef`. -- `#[new]` creates a `tp_new` trampoline which parses arguments, constructs - `C`, and a `ClassNewDef` which moves it into a new Python allocation. +`#[pymethods]` accepts: -All generated slots retain CPython's existing ABI and panic containment. -Executable callbacks enter the common context machinery, including authorized -cleanup entry during finalization; traversal uses only the restricted visitor -capability. Runtime type references belong in module state using its traced -field policy; `add_type` accepts the safe untyped upcast. +- `#[pyfunction]`: an instance method with a `PyCMethod` trampoline, taking + `&C::State`; +- `#[getter]` and `#[setter]`: halves of a `ClassGetSetDef`; +- `#[new]`: a `tp_new` trampoline that parses arguments, constructs `C`, and + moves it into a new allocation. +It emits `C::PY_METHODS`, `C::PY_GETSETS`, and `C::PY_NEW`, installed with +`methods`, `getsets`, and `constructor`. All function-glue rules apply. -#### Argument parsing - -Argument parsing exposes a small PyO3-like signature syntax: +### Argument parsing ```rust #[pyfunction(signature = (data, value = 1, /))] @@ -1546,291 +1222,93 @@ fn checksum<'a, 'py>( } ``` -The macro checks parameter names and emits a static `ParamSpec`. Supplied -values use `FromPyObject` to extract Rust types; defaults are Rust expressions. - -Vectorcall binding uses a fixed stack array and allocation-free keyword -matching. Slots become `Borrowed<'args, 'py, PyAny>` without refcount traffic; -`CallbackArgs` lends `'args`, while `with_current` supplies `'py`. Constructor -tuple/dict parsing fills the same slots and shares conversion code. - -Too many, duplicate, unexpected, or missing arguments raise `TypeError`. The -initial grammar grows only as modules require more features. - - -#### Testing - -CI should run `cargo fmt` and -[Clippy](https://github.com/rust-lang/rust-clippy). - -A new `xxtestrustapi` module will be introduced to the normal Python test suite -to ensure the correctness of the implementation and check invariants of the -Rust code. The design requires the following focused coverage before enabling -its support declarations: - -- Compile-fail cases for mixing invariant scopes, returning scoped handles - or Python-backed Rust references from an entry, treating a static `Py` - owner as permission for static payload access, and using a context captured - directly or through scoped TLS while that context is exclusively borrowed. - Include attachment-dependent views held across detach, protocol calls, - explicit decref, and release drains. Compile-pass cases must allow opaque - handles and non-`Send` Rust captures/results in both detachment variants, and - exact immutable bytes views whose owner and entry outlive detachment. -- Regressions modeled on PyO3 #2141 and #3640: `SendWrapper`, a capture-free - function reading scoped TLS, and dynamic exclusive TLS/`RefCell` borrowing. - Check implicit formatting, comparison, conversion, iteration, and destruction - as well as explicit methods. A handle reachable while detached must remain - opaque without an available context. -- Interpreter A -> Python protocol -> interpreter B -> Rust callback reentry, - including a TLS hook holding A's values. Repeat with a different thread - state in A. Old contexts stay inaccessible; callback scopes use the actual - state, and return/unwind restores the exact outer state. An extensible - conversion trait must receive an available context, not one that was - suspended around the whole trait invocation. -- Cooperating foreign wrappers under `interop`, allowed nested detachment, - and native preflight refusal with the flag and attachment unchanged. - Subprocess tests for uncooperative public and internal detach/swap calls - must fail before invalidating an available context. Include direct Rust TLS - access from the detached closure, without a generated callback entry. - Forgetting public handles must not affect the flag or lease ownership. -- Strict `detach` refusal must not run its closure. `detach_if_possible` - must run it exactly once on either the detached path or the attached - fallback, preserve its return value or panic, and restore the exact state. - Include cleanup-phase fallback, permitted nested callbacks, and rejection - of forbidden foreign transitions from the closure; fallback does not grant - additional cleanup permissions. -- Contended `PyMutex` and critical-section parking must use the audited - internal transition path without rejecting a valid leaf operation. Paths - that handle signals or pending calls must use exclusive access. Verify - exact restoration, no callbacks during exempt parking, and lifetime - protection throughout; public release calls cannot claim the exemption. -- Two simultaneous subinterpreters, including separate-GIL configurations: - distinct module state, runtime classes, and exceptions; checked binding, - conversion, explicit destruction, and traced-field publication/mutation - reject foreign handles before reading their pointers. Repeat with multiple - module instances in one interpreter and with extension import checks off. -- Legitimately shared constants obtained independently in two interpreters: - pointer equality or immortality does not permit relabeling a stored handle. - Interpreter destruction and runtime reinitialization cannot revive old - keys, even if allocator addresses or public interpreter IDs are reused. - Include an old interpreter view resolved after ID reuse, and static - singleton drop elision before and after an upcast loses that knowledge. -- Attached, detached, wrong-interpreter, and unwinding drops: one ownership - transfer and at most one decref per handle, correct interpreter on release, - and preservation of any pending exception. Include TLS/native-thread drops - after finalization and `Py::drop_with` mismatch behavior. An implicit drop - while a shared context is exposed must never run a finalizer synchronously. - Exercise inline sink overflow and nested scopes, including a `Py` drop - inside a nested entry, which must use the interpreter queue; no queue node - may retain a pointer to a retired entry. - Exercise the sink aliasing invariant with reentrant and unwinding drops - during a batch drain, including Rust aliasing checks where the harness - supports them. -- Fault-injected queue allocation failure, full ordinary pending-call queues, - and admission/notification races with shutdown: accepted requests drain - before teardown; rejected requests neither dereference nor decref objects. - Include producers paused at each admission boundary and finalizers that - reenter Rust or enqueue further releases, without locks held across Python. - After external admission closes, authorized cleanup drops still drain; - unauthenticated producers cannot acquire cleanup permission. Test cascades - of queued drops from module/type teardown after the first final drain. - Include an active consumer with an empty queue whose C deallocator detaches: - its batch and interpreter must remain protected until draining completes. - A finalizing thread waiting for outstanding guards must wake and service - new releases, including submissions racing entry into its native wait. - Also pause a producer after its node is drained but before its submission - bookkeeping finishes: producer completion must wake the waiting finalizer. - Cover completion racing entry into the wait and retain last-guard wakeup - coverage. -- Shutdown racing with an active detached Rust scope: its state remains live - through reattachment; cleanup reentries after normal admission closes work - and cannot request explicit detach. Test audited internal temporary detach - during cleanup as well. Repeat cross-extension reentry with two `cdylib`s - to verify the CPython-owned flag and lifetime bookkeeping. Nested entries - must still acquire their own leases while a finalizing thread waits for the - guard count to reach zero. Deleting a protected thread state from another - OS thread must be rejected, including a state protected only by a - suspended entry. -- A scoped native worker holds `&S` or `&C` while its entry thread blocks in - a Python callback that detaches internally, without calling Rust `detach` - or `interop`. Finalization must wait and the borrowed storage must remain - valid. Use barriers to establish that ordering, then unblock the callback - and worker and verify shutdown completes. Include free-threaded deferred - module/function roots; a timeout-only hang test is insufficient. -- Panic during detachment, parsing, conversion, and payload destruction: - exact attachment is restored, the entry lease survives panic reporting, - and a second panic or failed restoration cannot cross the C ABI. Include - finalizers triggered by replacing exceptions and allocation failure during - reporting. Owned errors caught and discarded in Rust leave no ambient error; - same-interpreter state transfer works and foreign errors are rejected. -- Fork with queued references and paused detached producers, including - surviving-thread scope restoration and child-side interpreter deletion: - no inherited lock, stale flag, or orphaned in-flight, lease, or protection - count prevents progress or allows access to a removed interpreter. -- Concurrent GC-slot reads, replacement, traversal, and clear: returned owned - references remain live, foreign slots cannot be published, reentrant visits - never overlap an exclusive payload borrow, closed slots reject replacement, - and heap-type/module cycles are collected even for Rust-only payloads. - Failed `replace` must return the identical incoming ownership without an - incref or queued release until the caller drops it. Cover both stopped-world - traversal and debug tracking of a fully initialized unpublished instance. - -Before adopting the `PyLock` prototype, add compile-fail coverage for a second -acquisition through the borrowed context and for using `guard.ctx()` across -guarded detachment. Test detached mutation, unwind restoration, cross-thread -exclusion, and the selected fresh-context recursive-acquisition policy. -Forgetting a guard may leave a locked mutex, but cannot leave stale scope -bookkeeping. Test partial-failure recovery independently of poisoning and -keep GC-visible Python edges outside unsupported lock storage. - -Run the relevant runtime cases on GIL and free-threaded builds. Also verify -that shared Rust payload references cannot expose unsynchronized interior -mutation, even when attachment is released and another thread calls Python. - -Measure leaf access, unbound binding, outermost and nested entry, exclusive -call suspension/resumption, GC-slot access, explicit decref, inline scoped -drops, and interpreter-queue overflow against equivalent C operations. -Include contention, tiny frequent callbacks, compression throughput, and -retained-memory measurements. Measure transition-hook overhead on Python/C -workloads that never enter Rust, both detached and attached fallback CPU work, -and any full-output copy against the current `PyBytesWriter` path. The intended -cost distribution is: - -- Attachment and phase checks at scope entry and transition boundaries, - with no repeated TLS validation for consecutive leaf operations. -- A thread-local flag write around potentially reentrant calls; no global - registry lookup, atomic operation, or allocation on the ordinary suspension - path. -- Key comparisons at unbound-handle and traced-field publication boundaries, - and lease admission plus a protection count update at every entry, nested - entries included. -- Inline enqueue for ordinary scoped drops, batched decref, and native queue - synchronization/allocation on overflow and for every `Py` drop. Explicit - release avoids that queue traffic. -- Refcount traffic for mutable GC-slot reads; no copy for exact immutable - bytes input or fresh allocation merely to capture an existing C exception. - -These are implementation targets, not measured performance guarantees. Tune -batch sizes and sink storage using those measurements before claiming parity -with equivalent C code. - -CI will check that every Python-touching safe `c_api` wrapper accepts a -context or sealed visitor/release capability, and that shared-context -wrappers have an audited leaf contract. Private capability constructors, -transition gates, restoration, and exception paths need separate audits; -a signature check cannot establish those proofs. Operations such as deferred -submission that use neither need an audited thread-state-free justification, -including their allocation, notification, and failure paths. - -### Appendix: Open decisions and future work - -The following items are intentionally kept out of the normative API sections: - -- **Reclamation after interpreter shutdown.** The normative policy safely - abandons references that cannot be submitted before shutdown or on queue - allocation failure. Registered, revocable ownership records and reserved - release storage could strengthen reclamation guarantees, at a cost to - handle construction, memory usage, and synchronization. -- **Buffer protocol.** No safe `PyBuffer` API is included until the design - distinguishes read-only and writable exporters; specifies contiguity, - format, alignment, ownership, and release on every exit path; and prevents - Rust slices from racing with Python mutation or escaping across detach and - reentrant calls. The exact immutable bytes accessor above provides the - initial zero-copy input path under a narrower ownership contract. -- **Interpreter-aware locking.** The design must either provide a lock which - cooperates with attachment, the GIL, and free-threaded stop-the-world pauses, - or constrain lock use to separately audited patterns, such as acquiring - a Rust-only lock inside a detached computation. The prototype below covers - another useful pattern but does not settle all locking policy. -- **Shutdown progress and module migration.** Per-entry leases intentionally - make shutdown wait for live Rust borrows and accepted entry-local releases. - A daemon thread blocked in a Python callback beneath a Rust entry can - therefore delay shutdown indefinitely. Modules that routinely invoke user - callbacks, such as a future Rust `functools` or `itertools`, need an explicit - migration decision about that behavior. Compute-oriented modules have less - routine exposure, but even `zlib` argument conversion can invoke an - arbitrarily blocking `__index__`; "short protocol hook" is not a lifetime - or progress guarantee. Restricting a future entry mode to opaque handles - would require a separate API proof and cannot weaken the current lease rule. -- **Additional GC containers.** Each container must prove interpreter - association at publication and mutation, correct edge accounting, safe - concurrent traversal/clear, and nonblocking visitor access before gaining a - sealed implementation. Guarded borrowed slot reads may avoid `get`'s incref - if their reentrancy and lifetime requirements can be expressed safely. -- **Explicit-state C optimization.** Benchmark explicit-`PyThreadState *` - variants for TLS-heavy hot paths; this would not change the safe Rust API. -- **Rust runtime and panic strategy.** The initial API requires `std` and - `panic=unwind` so generated callbacks can contain panics, complete lifecycle - cleanup, and report errors. A `panic=abort` build remains ABI-safe but cannot - provide that recovery behavior. Any future `no_std` configuration must use - panic-abort or provide another complete no-unwind boundary. - -#### Interpreter-aware locking prototype - -This is a candidate interface for Rust-only mutable payload state, not yet -a supported traced-field container or a completed locking API: - -```rust -impl PyLock { - pub fn lock<'g, 'py>( - &'g self, - ctx: &'g mut ThreadContext<'py>, - ) -> Guard<'g, 'py, T>; -} - -impl<'g, 'py, T> Guard<'g, 'py, T> { - pub fn ctx(&self) -> &ThreadContext<'py>; - - pub fn detach( - &mut self, - f: impl FnOnce(&mut T) -> R, - ) -> Result; -} -``` - -Acquisition uses an audited `PyMutex` wait that parks detached without -dispatching pending calls. The exclusive context loan lasts until the guard -ends, so a second acquisition through that same context is a compile error. -`ctx()` permits shared leaf access only; a live borrow of its result prevents -an overlapping `Guard::detach` call. The guard remains `!Send + !Sync`. -The lock's cross-thread `Send`/`Sync` implementations require `T: Send` and -an audited synchronized representation, as for a mutex. - -`Guard::detach` lends `&mut T` to its closure while retaining the native -mutex for the entire call. It uses strict detachment and reports phase -refusal before running the closure. The mutable borrow cannot escape in -`R`. Private guards restore the exact thread state and availability flag -on return or panic independently of the public guard's eventual destruction. -The native lock must never be automatically released by a CPython critical -section or parking mechanism while any Rust reference to its protected data -is live; otherwise another caller could obtain overlapping mutable access. - -This prevents exclusive Python operations through the original context, -not every possible Python call while locked. The detached closure can use a -foreign wrapper to attach and invoke a generated callback with a fresh -context. That callback can attempt the same lock and deadlock, despite obeying -all scope brands. The API must choose and document ordinary nonrecursive -mutex behavior or native recursive-acquisition detection and an error result. -Lock ordering and cross-lock cycles remain separate progress concerns. - -The initial GC field policy does not accept Python edges hidden behind this -lock: traversal cannot acquire it or alias a live `&mut T`. Any future traced -lock container needs its own publication, traversal, and clear proof. A panic -may leave application state inconsistent, so a recovery or invalid-state -policy is required. Poisoning can aid recovery but cannot be the basis of -memory safety; payload methods and destructors must remain safe after partial -failure. Safe `mem::forget(guard)` may leave the mutex permanently locked and -end the Rust context loan. It must not leave the availability flag stale -or make restoration or interpreter lifetime protection depend on its drop. - -For `zlib`, this interface can hold a stream lock across detached compression, -but output growth must also fit the context rules. One candidate grows a -Rust-owned output buffer inside a single detached closure, unlocks, and then -copies into Python bytes. The current C `_BlocksOutputBuffer` delegates to -`PyBytesWriter`, whose finish operation can return its existing bytes object -after any needed resize. It does not always perform a final block-joining -copy. The Rust-buffer candidate can therefore add a full-output copy and -increase peak memory. Benchmark it against the current writer before choosing -it; an audited unpublished-output abstraction may offer another approach. -The prototype is not sufficient grounds to claim C-equivalent performance -or enable the first full `zlib` migration. +The macro checks names and emits a static `ParamSpec`. Values are extracted +with `FromPyObject`; defaults are Rust expressions. Vectorcall binding uses a +fixed stack array and allocation-free keyword matching, yielding +`Borrowed<'args, 'py, PyAny>` slots with no refcount traffic. Constructor +tuple/dict parsing fills the same slots. + +The glue checks its own inputs: a `ParamSpec`/slot-count mismatch is a +checked error, `kwnames` must be a tuple, and constructor keyword values are +borrowed from a private tuple, because a list could be cleared through +`gc.get_objects()` during extraction. Argument errors raise `TypeError`. + +## Testing + +CI runs `cargo fmt` and [Clippy](https://github.com/rust-lang/rust-clippy), +and checks that every Python-touching safe `c_api` wrapper takes a context +or sealed capability, and that shared-context wrappers have an audited leaf +contract. + +A new `xxtestrustapi` module, run by the regular test suite, covers testing the new Rust API correctness. + +### Performance + +Rust code must match or be very close to C code in performance. Rust APIs should be measured against equivalent C: leaf access, binding, entry (outermost and +nested), suspension, slot access, explicit decref, inline drops, queue +overflow, contention, tiny callbacks, compression throughput, retained +memory, gate overhead on code that never enters Rust, detached versus +attached-fallback CPU work, and output copying versus `PyBytesWriter`. + +## Appendix: Open decisions and future work + +- **Upstream CPython prerequisites.** These bugs reproduce without Rust: + - A PEP 788 guard held by a thread that does not survive `fork()` stays + counted, hanging the child's finalization. Leases avoid this by being + per thread state, but public guards can be handed between threads, so a + guard from a vanished thread may belong to the survivor. Guards need an + ownership or fork-invalidation contract. + - A daemon thread blocked in a subinterpreter ended by main-interpreter + finalization can keep a dangling interpreter pointer and read freed + memory on reattach. Leases make this reliable; the fatal hang check does + not fix it. + - A guard holder waiting on a pending call scheduled after the finalizer's + last `_Py_FinishPendingCalls` deadlocks. Scheduling must wake + finalizers, and the finalizer must run pending calls before parking. +- **Static builds.** Static builds register modules through `PyInit_*` + inittab entries, so `PyModExport_` needs inittab support. +- **Reclamation after shutdown.** Revocable ownership records and reserved + storage could release references that are currently abandoned, at a cost + to every handle. +- **Buffer protocol.** A safe `PyBuffer` must distinguish read-only and + writable exporters, specify contiguity, format, alignment, ownership, and + release, and prevent slices from racing Python mutation or escaping across + detach. +- **Locking.** A lock that cooperates with attachment, the GIL, and + stop-the-world is needed, and could be modeled after [PyO3's `PyOnceLock`](https://github.com/PyO3/pyo3/blob/4045f8d92045fbf55438eb6cae175b853c5835ce/src/sync/once_lock.rs#L42). +- **Shutdown progress.** Leases let a daemon thread blocked in a callback + under a Rust entry delay shutdown indefinitely. This should be re-evaluated. +- **Mutable GC containers.** Some constructs need a collection of traced + Python references that changes after the object is created: a cache + such as `functools.lru_cache`, a registry of callbacks, or the set of + pending tasks in `_asyncio`. The API cannot express these yet. Traced + edges live only in `GcSlot` or in containers of slots that cannot + change through `&`, and payloads are only reachable through `&self`. Such + a module today has to keep its references in untraced `Mutex` or + `RefCell` storage, where cycles through them leak. Mutable containers + such as a `GcVec` or `GcMap` would fill that gap, but each must + prove what `GcSlot` does: + - every reference is checked against the container's interpreter key + when it is inserted, not at GC time; + - traversal reports each strong reference exactly once; + - traversal reads without locking, so no thread may be paused + mid-mutation while holding the container's lock; + - clear closes the container, so finalizers cannot re-add edges, and + releases references only after the lock is dropped. + + A naive `Mutex>>` fails the third requirement. Traversal + cannot take the mutex, because its holder may be paused by + stop-the-world, but reading without the lock races with a `push` that + reallocates and frees the buffer. +- **Borrowed `GcSlot` reads.** `GcSlot::get` increfs on every read, because + another thread can replace the slot and decref the old value. A guarded + read could instead return a `Borrowed` tied to a guard that blocks + replacement. Since a slot's lock holder must never park or run callbacks, + the guard would have to allow only leaf operations, for example by + borrowing `&ThreadContext` for its whole lifetime. Whether a reader may be + paused on a contended lock while holding the guard is still open. +- **Explicit thread state C APIs** and inlining the entry counters could speed hot + paths without changing the Rust API. +- **Panic strategy.** The API requires `std` and `panic=unwind`. A + `panic=abort` build is ABI-safe but cannot recover; a `no_std` build would + need panic-abort or another no-unwind boundary. From 4a616648796c80fa929d41c324f9ac1e15415228 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Wed, 14 Oct 2026 19:19:31 -0700 Subject: [PATCH 05/16] Update API design draft based on a few review comments --- RUST_API.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index 4fd805b4e50c79f..e1a51438a133fc7 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -660,7 +660,7 @@ pub type PyResult = Result; `PyErr` owns its exception, with `Py`'s ownership and interpreter association, rather than marking one pending on the thread state. Dropping it queues the reference, so recovering in Rust leaves no hidden pending -exception. +exception. It is [`#[must_use]`](https://doc.rust-lang.org/std/attribute.must_use.html) to ensure that API users do not unintentionally miss an exception return. Exception types provide `raise(&mut ctx, message)`, which returns an owned `PyErr`; the exception is set only when the generated boundary restores it: @@ -1177,7 +1177,7 @@ builds; mutable state needs interior mutability. ```rust #[derive(PyClassGc)] struct Node { - parent: GcSlot>, + parent: GcSlot>, value: u64, } ``` @@ -1225,7 +1225,7 @@ fn checksum<'a, 'py>( The macro checks names and emits a static `ParamSpec`. Values are extracted with `FromPyObject`; defaults are Rust expressions. Vectorcall binding uses a fixed stack array and allocation-free keyword matching, yielding -`Borrowed<'args, 'py, PyAny>` slots with no refcount traffic. Constructor +`Borrowed<'args, 'py, PyAny>` slots with no refcount churn. Constructor tuple/dict parsing fills the same slots. The glue checks its own inputs: a `ParamSpec`/slot-count mismatch is a From 8fc79cfbbd12517b6d7238fcc0c897e308a52147 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Wed, 14 Oct 2026 19:20:04 -0700 Subject: [PATCH 06/16] Ensure function arguments that are reachable from Python are never borrowed --- RUST_API.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index e1a51438a133fc7..cd4075c8d32afdb 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -1228,10 +1228,24 @@ fixed stack array and allocation-free keyword matching, yielding `Borrowed<'args, 'py, PyAny>` slots with no refcount churn. Constructor tuple/dict parsing fills the same slots. -The glue checks its own inputs: a `ParamSpec`/slot-count mismatch is a -checked error, `kwnames` must be a tuple, and constructor keyword values are -borrowed from a private tuple, because a list could be cleared through -`gc.get_objects()` during extraction. Argument errors raise `TypeError`. +The glue checks its own inputs and keeps every argument alive while it is +in use: + +- A `ParamSpec`/slot-count mismatch is a checked error. +- `kwnames` must be a tuple before its entries are read. +- Constructor keyword values are never borrowed from the `kwargs` dict. + Converting one argument can run Python code, such as `__index__`, that + removes another value from the dict, which would free a borrowed value. + Before converting any argument, the parser takes its own references to + every keyword value, and it keeps them until the call and result + conversion finish. It stores them in a private tuple rather than a list, + because Python code can find a list through `gc.get_objects()` and clear + it. +- Positional arguments and vectorcall arrays can be borrowed without a + copy: the caller keeps them alive for the whole call, and Python code + cannot modify them. + +Argument errors raise `TypeError`. ## Testing From 8d3e187039b40a70c5f8805bed7658ae10ef7af0 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Wed, 14 Oct 2026 19:48:18 -0700 Subject: [PATCH 07/16] Tweak handling of pending exceptions to be more robust with interop() --- RUST_API.md | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index cd4075c8d32afdb..181fa7617b35506 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -372,9 +372,11 @@ impl<'py> ThreadContext<'py> { deadlock through the GIL or stop-the-world. - **`interop`** suspends without detaching, so `f` can use a foreign safe wrapper that manages its own attachment, including switching - interpreters. The boundary verifies the incoming state was restored. - Cleanup entries refuse it. It does not repair a foreign wrapper's own - soundness. + interpreters. The boundary verifies the incoming state was restored. If + `f` leaves an exception pending, `interop` takes it and returns + `InteropError::Exception(PyErr)` instead of `f`'s result, so the caller + can propagate or handle it. Cleanup entries refuse it. It does not repair + a foreign wrapper's own soundness. - **`drain_releases`** services queued releases (see [Draining](#draining)). The closures receive no context and need no `Send` bounds: they run on the @@ -472,9 +474,12 @@ Sealed markers for immortal singletons (`None`, booleans) may skip release entirely; a general integer marker may not. `Drop` must not unwind. Explicit release and draining preserve the incoming -exception. Generated deallocators report panics as unraisable, setting -aside any propagating exception while building the report so the caller's -exception is not replaced (debug builds abort otherwise). +exception. Like any deallocator, generated `tp_dealloc` and module free must +leave the exception state exactly as they found it; CPython debug builds abort +otherwise. They save and clear any pending exception before cleanup starts, +and restore exactly what they saved, including no exception, on every exit +path. A panic, or an exception left behind by cleanup, is reported as +unraisable before the restore. #### Markers and GC @@ -696,6 +701,16 @@ checked first; a foreign-interpreter error is queued under its own key and replaced with `SystemError`. Restoring an exception may decref the previous one, so it uses the exclusive-call protocol. +Because a `PyErr` carries its own exception, a later error cannot be confused +with an earlier one. This relies on one invariant: **while a context is +available, the error indicator is clear.** Otherwise a stale exception could +be captured as a later call's error, for example after an ambiguous `-1`. +Wrappers maintain the invariant by capturing exceptions immediately. Entries +that CPython may invoke with an exception pending (`tp_dealloc` and module +free) save and clear it first (see [Dropping](#dropping)). Other entries +debug-assert a clear indicator, and `interop` turns an exception left pending +by foreign code into an `InteropError`. + The built-in exception marker trait is sealed because its type pointer goes straight to C. Custom exception types stored as `Py` need checked binding. Converting a native error (identity, entry, detach, interop) into a @@ -925,6 +940,10 @@ spelling: - `Arc>` and `Rc>` are compile errors, even behind an alias, because shared ownership would double-report edges. The error fires after monomorphization. +- `Py` is rejected by default, even inside a wrapper or behind an alias, + because the collector cannot see it and a cycle through it would leak. + Use `GcSlot` instead, or mark a field `#[gc(untraced)]` to keep a + reference that cannot be part of a cycle. - Anything else is untraced; a slot hidden in another wrapper leaks its cycles, which is safe. @@ -1080,7 +1099,7 @@ outer catch is a last-resort abort. | Module exec | Commit the appropriate failed state phase, set a fixed `SystemError`, and return `-1`. | | Module or instance clear | Finish the lifecycle transition and set `SystemError`, which CPython reports as unraisable. Instance clear returns failure. Module clear returns 0, because CPython skips clearing the module dict when `m_clear` fails. | | Traverse | Preserve incoming exception state and propagate the visitor's exact nonzero return and any error it sets. A Rust panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | -| `tp_dealloc` and module free | Run mandatory non-panicking cleanup guards first, then report the panic as unraisable when an attached state is available. Set aside any pending exception while building the report and restore it afterwards. Abort if cleanup itself cannot be completed safely. | +| `tp_dealloc` and module free | Save and clear any pending exception before cleanup. Run mandatory non-panicking cleanup guards, then report the panic as unraisable when an attached state is available. Restore the saved exception, or none, afterwards. Abort if cleanup itself cannot be completed safely. | Before dropping a payload or module state, deallocation commits the destroyed phase and installs cleanup guards, so a panic cannot cause a From ff6819c8f08552fe74f31893092cbce3fbe54e64 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Thu, 15 Oct 2026 13:41:43 -0700 Subject: [PATCH 08/16] Apply wording suggestion from Carol Co-authored-by: Carol Willing --- RUST_API.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index 181fa7617b35506..18e1d54198ad8a1 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -9,12 +9,11 @@ features. Keeping either module's buffer-accepting interface needs the buffer work in the appendix, and stateful `zlib` also needs the locking and output-buffer designs there. -Rust can call the C API efficiently, but raw FFI does not encode Rust's +Rust can call the C API efficiently. However, raw FFI does not encode Rust's lifetime, aliasing, or thread-safety rules. The wrapper described here -restores them. It depends on new private CPython support (a per-thread +restores them. The design depends on new private CPython support (a per-thread availability flag, per-thread-state protection and lease counts, transition -gates, and a native release queue), so it is not a safe model for an -unmodified runtime. Several existing CPython bugs also interact with it; the +gates, and a native release queue), so a modified runtime is needed for safety. Several existing CPython bugs need fixes; the appendix lists them as upstream prerequisites. ## Goals From db9f214e3144ee8f848d47761f2154501025f303 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Thu, 15 Oct 2026 20:02:59 -0700 Subject: [PATCH 09/16] Require closing admission to wait for threads to detach or ownership of the GIL --- RUST_API.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/RUST_API.md b/RUST_API.md index 18e1d54198ad8a1..ccfbb38ab976a4c 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -185,6 +185,15 @@ its own leases: - A state with a nonzero lease count is protected from deletion. After fork, vanished states' counts disappear with them. +Lease acquisition requires attachment to the target interpreter throughout +the admission check and counter increment. No safepoint, detachment, or +reentrant call may occur between them. Closing admission requires a +completed stop-the-world handshake, or ownership of that interpreter's GIL, +held continuously through the final recheck and closure. The synchronization +must make preceding lease increments visible to the finalizer and admission +closure visible to subsequent entrants. Merely requesting a stop is +insufficient. + PEP 788 C guards keep their shared counter, because a guard can be created without an attached thread state and closed on another thread. From 734b718fd59b17a86b8faef4ae6cc885858770c1 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 04:06:08 -0700 Subject: [PATCH 10/16] A few hardenings/tweaks to lifecycle and finalization --- RUST_API.md | 147 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 86 insertions(+), 61 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index ccfbb38ab976a4c..7118d5180943f91 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -145,12 +145,15 @@ The cost is that an entry blocked indefinitely in a Python callback blocks shutdown indefinitely (see *Shutdown progress* in the appendix). This is deliberate. -Two fatal checks prevent leases from deadlocking: - -- **Finalizing a leased interpreter.** A Rust callback may run code that - calls `Py_EndInterpreter` on an interpreter it holds a lease on. When - finalization starts, CPython fails fatally if the finalizing OS thread - holds a lease on that interpreter through any of its thread states. +Two fatal checks reject direct lease-dependent shutdown cycles: + +- **Finalizing while holding a lease.** `Py_EndInterpreter` and + `Py_FinalizeEx` fail fatally at the start of finalization if the calling OS + thread holds any lease, across all its thread states and interpreters. + Otherwise two threads could hold leases on A and B while each finalizes + the other. + Finalization scans the existing counts under the runtime's thread-list + lock; this adds no bookkeeping to ordinary entry or exit. - **Hanging a leased thread.** A thread may hold a lease on A while running in B (e.g. via `interp.exec()`). If B's finalization hung it, A could never finish finalizing. CPython's hang paths, including the one in @@ -182,8 +185,7 @@ its own leases: - **Close:** once the sum is zero, stop the world (or hold the GIL), recheck every count, the guard counter, and the release queue, and set the flag only if all are clear; otherwise restart and wait again. -- A state with a nonzero lease count is protected from deletion. After fork, - vanished states' counts disappear with them. +- A state with a nonzero lease count is protected from deletion. Lease acquisition requires attachment to the target interpreter throughout the admission check and counter increment. No safepoint, detachment, or @@ -287,9 +289,10 @@ crossing a generated entry. CPython therefore adds: internal, reject any transition while the flag is set, even with no state attached. Deletion also rejects a state with a nonzero protection count, even from another thread. -4. **Resume:** require the flag clear and the original state attached, then - set the flag. If a foreign call failed to restore the state, fail fatally - rather than attach over an unknown state. +4. **Resume:** require the flag clear and the original state attached, apply + the [exception policy](#exceptions), then set the flag. If a foreign call + failed to restore the state, fail fatally rather than attach over an + unknown state. 5. **Exit:** clear the flag, service or transfer the entry's releases, and retire the sink once empty. Cleanup and panic reporting precede releasing the protection count and lease. @@ -321,12 +324,14 @@ distinguishes it from explicit detachment. Lock paths that call `Py_MakePendingCalls` for signals use the exclusive-call protocol instead. The gate does not stop foreign code from running Python on the attached -state (`PyObject_Call` from another library, or `PyErr_CheckSignals`). That -violates the foreign library's own contract, and every Rust boundary it can -reach checks the flag: a nested entry or gated detach fails fatally, GC +state (`PyObject_Call` from another library, or `PyErr_CheckSignals`). Such +use need not violate the foreign library's contract. Every Rust boundary it +can reach checks the flag: a nested entry or gated detach fails fatally, GC clear of a Rust object is refused (leaking the cycle), and payloads are -dropped without a context as always. Transition hooks also affect non-Rust -code and must be benchmarked. +dropped without a context as always. Foreign code can also set an exception +without a transition; wrappers handle this through the [exception +policy](#exceptions). Transition hooks also affect non-Rust code and must +be benchmarked. #### Entry glue The following is a simplified example of what the Rust entrypoint glue for a function call looks like: @@ -359,7 +364,7 @@ impl<'py> ThreadContext<'py> { pub fn detach_if_possible( &mut self, f: impl FnOnce() -> R, - ) -> R; + ) -> PyResult; pub fn interop( &mut self, @@ -373,11 +378,12 @@ impl<'py> ThreadContext<'py> { - **`detach`** suspends the context, detaches, runs `f` on the same thread, and restores the state, including on panic. Refusal returns `DetachError` before detaching. Cleanup entries refuse it. Nested callbacks may detach. + Pending exceptions use `DetachError::Exception(PyErr)`. - **`detach_if_possible`** detaches when the phase permits and otherwise runs - `f` attached, exactly once either way. Use it only for work valid while - attached, such as audited compression. Code that waits on another thread - needing Python must use strict `detach`, because attached waits can - deadlock through the GIL or stop-the-world. + `f` attached, after ensuring a clean exception state. Pending + exceptions return `Err(PyErr)`. Use it only for work valid while attached. Code that waits on another thread needing + Python must use strict `detach`, because attached waits can deadlock + through the GIL or stop-the-world. - **`interop`** suspends without detaching, so `f` can use a foreign safe wrapper that manages its own attachment, including switching interpreters. The boundary verifies the incoming state was restored. If @@ -387,6 +393,10 @@ impl<'py> ThreadContext<'py> { a foreign wrapper's own soundness. - **`drain_releases`** services queued releases (see [Draining](#draining)). +The three closure APIs return any preexisting exception through their error +channel without running `f`. On return they capture pending exceptions +before restoring availability, following the [exception policy](#exceptions). + The closures receive no context and need no `Send` bounds: they run on the same thread, and the proof is exclusive access to the capability. Handles may move in and out but remain unusable without an available context. @@ -574,15 +584,19 @@ A finalizer parked waiting for guards and leases needs a native wakeup, and whoever releases the last guard or lease must not touch the interpreter, which may be freed at once. The wake word and a waiting-finalizer count therefore live in process-global storage shared by all finalizers; wakeups -for other interpreters are harmless. Three events wake finalizers: a -producer setting `releases_pending`, release of the last Rust lease, and -`PyInterpreterGuard_Close` releasing the last C guard. +for other interpreters are harmless. Wake events are a producer setting +`releases_pending`, release of the last Rust lease or C guard, and the +in-flight submission count reaching zero. After releasing its final +lifetime protection, a producer accesses only process-global notification +storage. A waker does nothing if the waiter count is zero; otherwise it increments the -word and unparks. The finalizer registers as a waiter first, then on each -iteration reads the word, services releases, checks the counts, and parks -expecting the word it read. With sequentially consistent atomics no wakeup is -lost. The loop should also run pending calls before parking, and scheduling a +word and unparks **all** waiters (`_PyParkingLot_UnparkAll`). A single wake +could select another interpreter's finalizer and leave the ready one asleep. +The finalizer registers as a waiter first, then on each iteration reads the +word, services releases, checks the counts, and parks expecting the word it +read. With sequentially consistent atomics no wakeup is lost. The loop should +also run pending calls before parking, and scheduling a pending call should wake finalizers; otherwise a guard holder waiting on a pending call scheduled after the last `_Py_FinishPendingCalls` deadlocks (an existing bug; see the appendix). @@ -648,16 +662,7 @@ ownership records on every handle (see the appendix). #### Fork -The registry, sinks, availability flag, and protection and lease counts join -CPython's fork protocol. Detached producers must be quiesced, and fork -preparation must not wait while holding a lock they need. The child -reinitializes synchronization, keeps accepted releases for surviving -interpreters, clears vanished states' counts before deleting the states, and -retires endpoints for removed interpreters. If after-fork cleanup would -delete a state protected by a suspended entry on the forking thread, the -child fails fatally. `PyOS_AfterFork()` without `PyOS_BeforeFork()` cannot be -made safe. The queue is not ready for general use until both sides are -tested. +Fork support with active Rust entries is still TBD. ### Exceptions @@ -685,45 +690,56 @@ fn checked_size(ctx: &mut ThreadContext<'_>, size: usize) -> PyResult { } ``` +Immediately before an error-sensitive C call, its wrapper checks for a +preexisting exception. If present, it takes ownership and returns an error +without making the call. Audited leaves may preserve the indicator; +reference release and deallocation follow their save/clear/restore policy. When a C call fails, its wrapper immediately takes the raised exception (with `_PyErr_GetRaisedException`, a pointer transfer) while it still controls the originating state: ```rust -// Within the private C-call wrapper, with its exclusive borrow held: +// After exception preflight and the C call, with the exclusive borrow held: if ptr.is_null() { // SAFETY: the audited C API's NULL result guarantees an exception on // this exact, restored state. Capture transfers the raised reference. Err(unsafe { take_raised_exception(call_state) }) } else { - // Transfer the owned result into the entry's Bound representation. + // Require a clear indicator before transferring the owned result. } ``` Ambiguous sentinels also check the error indicator. A missing exception -becomes `SystemError`. Failures while creating or reporting an error must -leave an appropriate exception such as `MemoryError`. - -At the boundary, `Ok` requires a clear error indicator. `Err` has its key +becomes `SystemError`. Success with a pending exception discards the result +under the exclusive-call protocol and returns the captured exception. +Failures while creating or reporting an error must leave an appropriate +exception such as `MemoryError`. + +At a generated callback boundary, a pending exception turns `Ok` into `Err`, +even if no wrapped operation followed author code. An explicit `Err` remains +primary; secondary exceptions are reported as unraisable. `Err` has its key checked first; a foreign-interpreter error is queued under its own key and replaced with `SystemError`. Restoring an exception may decref the previous one, so it uses the exclusive-call protocol. -Because a `PyErr` carries its own exception, a later error cannot be confused -with an earlier one. This relies on one invariant: **while a context is -available, the error indicator is clear.** Otherwise a stale exception could -be captured as a later call's error, for example after an ambiguous `-1`. -Wrappers maintain the invariant by capturing exceptions immediately. Entries -that CPython may invoke with an exception pending (`tp_dealloc` and module -free) save and clear it first (see [Dropping](#dropping)). Other entries -debug-assert a clear indicator, and `interop` turns an exception left pending -by foreign code into an `InteropError`. +Context availability proves attachment, not ownership of the error +indicator: safe foreign code can set it without crossing a gate. The +preflight and capture rules keep stale errors out of later C calls. +Function entries return unexpected pending exceptions before invoking author +code; `tp_dealloc` and module free save and restore them (see +[Dropping](#dropping)). + +Suspended author closures, including bytes builders, use the same capture +policy. Discarded results and panic payloads are dropped while suspended, +and the indicator is checked again afterwards. During unwinding, restoration +captures secondary exceptions for later unraisable reporting at the boundary; +it neither replaces the original panic nor unwinds itself. The built-in exception marker trait is sealed because its type pointer goes straight to C. Custom exception types stored as `Py` need checked -binding. Converting a native error (identity, entry, detach, interop) into a -`PyErr` requires an explicit mutable context; nothing consults ambient -attachment. +binding. Converting native failures into a `PyErr` requires an explicit +mutable context; detach/interop exception variants already own a `PyErr`. +Nothing consults ambient attachment. ### Concrete objects and conversions @@ -804,7 +820,7 @@ It does not incref or copy: ```rust let input = data.as_bytes(ctx); -let output = ctx.detach_if_possible(|| compress_without_python(input)); +let output = ctx.detach_if_possible(|| compress_without_python(input))?; ``` This proof does not extend to arbitrary buffer exporters (see the @@ -988,6 +1004,13 @@ CPython creates the module and binds its functions before allocating state; `importlib.util.module_from_spec` or `sys.modules` mid-import, so every callback handles missing storage. The macro rejects over-aligned types. +This requires a CPython publication fix: initialize the zero-valid storage +header before publishing `md_state`, synchronize pointer readers with that +publication, and serialize competing allocations. Current allocation stores +the pointer before zeroing it. Another thread can call an already-bound +function during exec; release/acquire on the phase alone cannot protect the +allocation containing that phase. + | Operation | Allowed source phase | Published phase and access | | --- | --- | --- | | Any callback before exec | No storage | Functions and methods raise `SystemError("module is not initialized")`; traverse, clear, and free do nothing. | @@ -1294,11 +1317,13 @@ attached-fallback CPU work, and output copying versus `PyBytesWriter`. ## Appendix: Open decisions and future work - **Upstream CPython prerequisites.** These bugs reproduce without Rust: + - Module-state allocation publishes `md_state` before zeroing it. Concurrent + callbacks need initialized storage and synchronized pointer publication + (see [State lifecycle](#state-lifecycle)). - A PEP 788 guard held by a thread that does not survive `fork()` stays - counted, hanging the child's finalization. Leases avoid this by being - per thread state, but public guards can be handed between threads, so a - guard from a vanished thread may belong to the survivor. Guards need an - ownership or fork-invalidation contract. + counted, hanging the child's finalization. Public guards can be handed + between threads, so a guard from a vanished thread may belong to the + survivor. Guards need an ownership or fork-invalidation contract. - A daemon thread blocked in a subinterpreter ended by main-interpreter finalization can keep a dangling interpreter pointer and read freed memory on reattach. Leases make this reliable; the fatal hang check does From d2e632ea9e07c72474293f05c6d5a8e5d16f883b Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 04:07:07 -0700 Subject: [PATCH 11/16] Number CPython bugs as part of discussion --- RUST_API.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index 7118d5180943f91..e298a27031b75e6 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -1004,13 +1004,6 @@ CPython creates the module and binds its functions before allocating state; `importlib.util.module_from_spec` or `sys.modules` mid-import, so every callback handles missing storage. The macro rejects over-aligned types. -This requires a CPython publication fix: initialize the zero-valid storage -header before publishing `md_state`, synchronize pointer readers with that -publication, and serialize competing allocations. Current allocation stores -the pointer before zeroing it. Another thread can call an already-bound -function during exec; release/acquire on the phase alone cannot protect the -allocation containing that phase. - | Operation | Allowed source phase | Published phase and access | | --- | --- | --- | | Any callback before exec | No storage | Functions and methods raise `SystemError("module is not initialized")`; traverse, clear, and free do nothing. | @@ -1317,18 +1310,18 @@ attached-fallback CPU work, and output copying versus `PyBytesWriter`. ## Appendix: Open decisions and future work - **Upstream CPython prerequisites.** These bugs reproduce without Rust: - - Module-state allocation publishes `md_state` before zeroing it. Concurrent + 1. Module-state allocation publishes `md_state` before zeroing it. Concurrent callbacks need initialized storage and synchronized pointer publication (see [State lifecycle](#state-lifecycle)). - - A PEP 788 guard held by a thread that does not survive `fork()` stays + 2. A PEP 788 guard held by a thread that does not survive `fork()` stays counted, hanging the child's finalization. Public guards can be handed between threads, so a guard from a vanished thread may belong to the survivor. Guards need an ownership or fork-invalidation contract. - - A daemon thread blocked in a subinterpreter ended by main-interpreter + 3. A daemon thread blocked in a subinterpreter ended by main-interpreter finalization can keep a dangling interpreter pointer and read freed memory on reattach. Leases make this reliable; the fatal hang check does not fix it. - - A guard holder waiting on a pending call scheduled after the finalizer's + 4. A guard holder waiting on a pending call scheduled after the finalizer's last `_Py_FinishPendingCalls` deadlocks. Scheduling must wake finalizers, and the finalizer must run pending calls before parking. - **Static builds.** Static builds register modules through `PyInit_*` From 6eeffbebd4fd09863957804667f470a25a394747 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 04:08:45 -0700 Subject: [PATCH 12/16] Remove some unneeded detail in the first paragraph --- RUST_API.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index e298a27031b75e6..3f85268e469d68c 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -2,12 +2,7 @@ ## Overview -This document designs a safe Rust API for in-tree CPython extension modules. -`zlib` is the first full migration target and drives the initial -requirements; `_base64` and `Encoder` examples illustrate individual -features. Keeping either module's buffer-accepting interface needs the -buffer work in the appendix, and stateful `zlib` also needs the locking and -output-buffer designs there. +This document designs a safe Rust API for in-tree CPython extension modules. `zlib` is the first full migration target. Rust can call the C API efficiently. However, raw FFI does not encode Rust's lifetime, aliasing, or thread-safety rules. The wrapper described here From 33172ba23a652af8fb4c54c3257eeadd919e1102 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 04:29:36 -0700 Subject: [PATCH 13/16] s/xxtestrustapi/_testrustapi/ Co-authored-by: Petr Viktorin --- RUST_API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RUST_API.md b/RUST_API.md index 3f85268e469d68c..0086fc638797ce2 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -1292,7 +1292,7 @@ and checks that every Python-touching safe `c_api` wrapper takes a context or sealed capability, and that shared-context wrappers have an audited leaf contract. -A new `xxtestrustapi` module, run by the regular test suite, covers testing the new Rust API correctness. +A new `_testrustapi` module, run by the regular test suite, covers testing the new Rust API correctness. ### Performance From 794b28f6550ed32dcd96d6179523bb0f1a622bf9 Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 11:24:21 -0700 Subject: [PATCH 14/16] Remove RefCell from module storage types Co-authored-by: alexey semenyuk --- RUST_API.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index 0086fc638797ce2..e8bb5f96bdcddfe 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -1338,8 +1338,7 @@ attached-fallback CPU work, and output copying versus `PyBytesWriter`. pending tasks in `_asyncio`. The API cannot express these yet. Traced edges live only in `GcSlot` or in containers of slots that cannot change through `&`, and payloads are only reachable through `&self`. Such - a module today has to keep its references in untraced `Mutex` or - `RefCell` storage, where cycles through them leak. Mutable containers + a module today has to keep its references in untraced `Mutex`, where cycles through them leak. Mutable containers such as a `GcVec` or `GcMap` would fill that gap, but each must prove what `GcSlot` does: - every reference is checked against the container's interpreter key From 3b378cceadf8142c073dfe7dd5f49d0730e9f57f Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 16:09:48 -0700 Subject: [PATCH 15/16] Add glossary and make wording more accessible --- RUST_API.md | 239 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 160 insertions(+), 79 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index e8bb5f96bdcddfe..be59d1468ee3c73 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -8,8 +8,61 @@ Rust can call the C API efficiently. However, raw FFI does not encode Rust's lifetime, aliasing, or thread-safety rules. The wrapper described here restores them. The design depends on new private CPython support (a per-thread availability flag, per-thread-state protection and lease counts, transition -gates, and a native release queue), so a modified runtime is needed for safety. Several existing CPython bugs need fixes; the -appendix lists them as upstream prerequisites. +checks, and a release queue that accepts references without an attached +thread state), so a modified runtime is needed for safety. Several existing +CPython bugs need fixes; the appendix lists them as upstream prerequisites. + +## Glossary + +This glossary explains the design-specific uses of terms in this document. +Standard Rust terms use their usual meanings; in particular, +`&T` is a shared borrow and `&mut T` is an exclusive (mutable) borrow. + +- **Entry:** a call into Rust from Python or the C API, such as a module + function, method, or slot callback (`exec`, `clear`, etc.). Generated + **entry glue** wraps the Rust function; its private **entry guard** keeps + resources alive through argument parsing, the call, result conversion, + and cleanup. See [Entry glue](#entry-glue). +- **Execution phase:** the circumstances in which CPython calls Rust: + ordinary execution, finalization cleanup, or GC traversal. Each permits + different operations; see [Execution phases](#execution-phases). +- **Capability:** a value or reference that proves permission to perform an + operation. Here this is normally a borrow of `ThreadContext`, with + restricted alternatives for GC traversal and reference release. +- **Class payload:** the Rust data stored in a Python instance. See + [Classes](#classes). +- **Leaf operation:** an operation that preserves attachment and + cannot re-enter Python, invoke arbitrary Rust callbacks, run deallocators, + or reach a safepoint that suspends execution or permits re-entry. It may + still call other C functions. Examples include binding a handle to a + context and reading immutable bytes; see + [The thread context](#the-thread-context). +- **Safepoint:** a point in a runtime operation where the thread may pause + or service pending work, for example to handle a stop-the-world request. +- **Exclusive context access:** an `&mut ThreadContext` borrow. Operations + that can run callbacks or change attachment require it, and temporarily + suspend the context while making the call. This is Rust's exclusive + borrow, not a lock on the interpreter or its objects. +- **Available / suspended context:** an available context may be used by + safe Rust code. Suspension temporarily makes it unavailable while keeping + the original thread state protected. Suspension alone does not detach + the thread; see [Scope transitions](#scope-transitions). +- **Finalization lease:** keeps an interpreter alive for the duration of a + Rust entry, including while detached. See + [Finalization leases](#finalization-leases). +- **Admission:** permission to start an entry or submit a reference for + release. Closing admission during finalization prevents new work, apart + from the explicitly authorized cleanup described below. +- **Gate:** a runtime check that rejects a thread-state transition, such as + attachment, detachment, or deletion, when it would invalidate a Rust + context. See [Gated and exempt paths](#gated-and-exempt-paths). +- **Release sink / queue:** a sink buffers references awaiting `Py_DECREF` + for one entry; the queue holds them for an interpreter. **Draining** means + performing those deferred decrefs. See + [Deferred reference release](#deferred-reference-release). +- **Foreign code:** C or Rust code outside this API, including other Rust + wrappers of the C API. See + [Detachment and foreign wrappers](#detachment-and-foreign-wrappers). ## Goals @@ -82,8 +135,9 @@ may touch Python requires a capability: a borrow of `ThreadContext` (shared for audited leaf operations, exclusive for anything that can run callbacks or change attachment), or a restricted private capability for GC traversal or reference release. An operation without one needs an audited proof that -its whole call path is thread-state-free. Until there is async `Drop`/`Drop` with context, `Drop` never obtains a -capability; it only submits a release. +its whole call path does not access a thread state. Until there is async +`Drop`/`Drop` with context, `Drop` never obtains a capability; it only submits +a release. ### Interpreter identity and lifetime @@ -202,15 +256,16 @@ admission closes, an entry is authorized if and only if its attached thread state is the one finalizing that interpreter. Cleanup contexts cannot request explicit detachment or unrestricted interoperation; an audited C call may detach internally only if finalization cannot advance until it -returns. User code and foreign threads cannot manufacture authorization. It +returns. User code and other threads cannot manufacture authorization. It ends at the [final drain point](#shutdown). -#### Future native-thread entry +#### Future entry from threads created outside Python The initial API creates contexts only at generated C entries. A future safe -native-thread entry should build on `PyThreadState_Ensure` and -`PyThreadState_EnsureFromView`, take an explicit interpreter key rather than -trusting view lookup, acquire a lease before attaching, and report shutdown +API for threads created outside Python should build on +`PyThreadState_Ensure` and `PyThreadState_EnsureFromView`, take an explicit +interpreter key rather than trusting view lookup, acquire a lease before +attaching, and report shutdown failure without a Python exception. `PyGILState_Ensure` must not implicitly select the main interpreter. @@ -232,7 +287,7 @@ pub struct ThreadContext<'py> { The marker makes `'py` invariant and the context `!Send + !Sync`. It is neither `Copy` nor `Clone`, and only generated entry glue constructs it. -`'py` is a fresh brand per entry, and `Bound` and `Borrowed` are invariant in +`'py` is a fresh lifetime per entry, and `Bound` and `Borrowed` are invariant in it too, so separate entries cannot mix scoped values even within one interpreter. @@ -243,15 +298,31 @@ interpreter. The classification covers the entire call path, including failure handling, allocator hooks, and cleanup. A shared-context operation reports failures -as native errors, since raising would need exclusive access. New wrappers -default to exclusive until audited, and a blocking acquisition is never a -leaf. Attachment does not protect object contents from concurrent -mutation on free-threaded builds. +as Rust error values without setting Python's error indicator, since raising +would need exclusive access. New wrappers default to exclusive until audited, +and a blocking acquisition is never a leaf operation. Attachment does not +protect object contents from concurrent mutation on free-threaded builds. Holding `&mut ThreadContext` does not make the context unavailable. A private wrapper **suspends** it only around the actual reentrant C call or closure, and never passes a suspended context to author code. +#### Execution phases + +An **execution phase** determines which operations CPython permits when it +calls Rust. It is separate from whether a thread is attached or a context +is available or suspended. + +| Execution phase | Permitted operations | +| --- | --- | +| Ordinary execution | An entry holds a finalization lease and may use the operations allowed by its shared or exclusive context borrow, including explicit detachment with exclusive access. | +| Finalization cleanup | Callbacks use [cleanup authorization](#cleanup-authorization). They cannot request explicit detachment or unrestricted interoperation; an audited C call may detach internally only if finalization cannot advance until it returns. | +| GC traversal | Callbacks use the restricted GC visitor operations, without a general-purpose `ThreadContext`. They must obey the [GC slot synchronization](#gc-slot-synchronization) rules. | + +After the [final drain point](#shutdown), no entry can obtain a context. +The module and instance lifecycle states described later (such as `Ready` +and `Cleared`) track initialization and destruction separately. + #### Availability flag and protection count Borrowing alone cannot stop another safe Rust library from calling @@ -274,12 +345,13 @@ crossing a generated entry. CPython therefore adds: #### Scope transitions 1. **Enter:** require the flag clear; verify the attached state and - execution phase; acquire a lease; increment the protection count. Only + [execution phase](#execution-phases); acquire a lease (or use cleanup + authorization during finalization); increment the protection count. Only then set the flag and publish the context. -2. **Suspend:** clear the flag before a reentrant operation. Preflight - refusal leaves attachment and the flag unchanged without raising. The - restoration guard keeps the original state. Suspension does not broaden - the C operations the execution phase permits. +2. **Suspend:** clear the flag before a reentrant operation. Refusal by the + initial checks leaves attachment and the flag unchanged without raising. + The restoration guard keeps the original state. Suspension does not + broaden the C operations the execution phase permits. 3. **Gate:** CPython's attach, detach, swap, and deletion paths, public and internal, reject any transition while the flag is set, even with no state attached. Deletion also rejects a state with a nonzero protection count, @@ -316,7 +388,8 @@ A path is exempt only if no callback or Rust access can run before the exact state is restored. The parking lot calls the public `PyEval_ReleaseThread`/`PyEval_AcquireThread`, so the private marker is what distinguishes it from explicit detachment. Lock paths that call -`Py_MakePendingCalls` for signals use the exclusive-call protocol instead. +`Py_MakePendingCalls` for signals require exclusive context access and +suspension instead. The gate does not stop foreign code from running Python on the attached state (`PyObject_Call` from another library, or `PyErr_CheckSignals`). Such @@ -333,7 +406,8 @@ The following is a simplified example of what the Rust entrypoint glue for a fun ```rust // SAFETY: only generated C callbacks with an attached state and valid inputs -// may enter; the implementation performs scope entry and lifetime admission. +// may enter; the implementation checks the execution phase and establishes +// a context with a lease or cleanup authorization. unsafe fn with_current( f: impl for<'py> FnOnce(&mut ThreadContext<'py>) -> R, ) -> Result; @@ -343,9 +417,9 @@ unsafe fn with_current( reject author functions that name a specific lifetime such as `'static`. Generated wrappers convert results to raw C values before returning. The entry guard lives through parsing, conversion, unwinding, -release processing, and exception reporting. `EntryError` is native; -callback-specific glue reports it without manufacturing a context after -admission closes. +release processing, and exception reporting. `EntryError` is a Rust error +value; callback-specific glue reports it without manufacturing a context +after admission closes. #### Detachment and foreign wrappers @@ -374,8 +448,8 @@ impl<'py> ThreadContext<'py> { and restores the state, including on panic. Refusal returns `DetachError` before detaching. Cleanup entries refuse it. Nested callbacks may detach. Pending exceptions use `DetachError::Exception(PyErr)`. -- **`detach_if_possible`** detaches when the phase permits and otherwise runs - `f` attached, after ensuring a clean exception state. Pending +- **`detach_if_possible`** detaches when the execution phase permits and + otherwise runs `f` attached, after ensuring a clean exception state. Pending exceptions return `Err(PyErr)`. Use it only for work valid while attached. Code that waits on another thread needing Python must use strict `detach`, because attached waits can deadlock through the GIL or stop-the-world. @@ -421,16 +495,17 @@ No implicit operation (`Debug`, equality, hashing, indexing, iteration, supplies `'py`. `Bound` stores the pointer and a pointer to its entry's release sink, which records the interpreter key. `Py` stores the pointer and an `InterpreterKey`. Both owning forms are two words on 64-bit targets. -The scope brand proves association, not permission: handles can exist while -the context is suspended but cannot be used without an available context. +The entry's `'py` lifetime proves association, not permission: handles can +exist while the context is suspended but cannot be used without an available +context. #### Binding Every safe `Py` operation that touches the object (binding, downcasting, cloning, conversion, destruction) first compares its key with the -context's, before dereferencing anything. Mismatch is a native -`InterpreterMismatch`. This also rejects expired handles whose address a -later interpreter reuses. +context's, before dereferencing anything. Mismatch returns the Rust error +`InterpreterMismatch` without setting Python's error indicator. This also +rejects expired handles whose address a later interpreter reuses. ```rust impl Py { @@ -475,7 +550,8 @@ published memory. Shared class and module payloads must be `Send + Sync`. object.drop_with(ctx)?; ``` -`drop_with(&mut ctx)` releases directly under the exclusive-call protocol. +`drop_with(&mut ctx)` releases directly with exclusive context access and +the context suspended during the decref. For `Py`, it returns `InterpreterMismatch` on mismatch after falling back to an ordinary queued drop. @@ -496,9 +572,9 @@ unraisable before the restore. #### Markers and GC -`PyAny` is the erased marker; specific markers like `PyBytes` share its -transparent layout, so upcasts are free. There is no `&mut PyAny`, since -Python objects can change through aliases. +`PyAny` is the marker for any Python object; specific markers like `PyBytes` +share its transparent layout, so upcasts are free. There is no `&mut PyAny`, +since Python objects can change through aliases. A `Py` in pure Rust state may stay opaque after its interpreter expires. Publishing it as a traced GC edge requires association with the containing @@ -511,8 +587,9 @@ object's interpreter at construction and every mutation. Each entry has a release sink with a small inline buffer, owned by the entry guard. `Bound::drop` appends to it with no allocation, lock, or Python call. When it fills, it transfers a batch to the interpreter queue or obtains -Python-free overflow storage, never decrefing in `Drop`. If both fail, only -the incoming reference is abandoned; accepted ones are kept. +overflow storage without calling Python or attaching a thread state, never +decrefing in `Drop`. If both fail, only the incoming reference is abandoned; +accepted ones are kept. **Aliasing invariant:** no `&mut` to sink storage may be live across an operation that can drop a handle into that sink. Pushes use interior @@ -527,8 +604,8 @@ authorized cleanup sinks overflow through a private cleanup route. #### The release queue -CPython owns one native release queue per interpreter lifetime, behind a new -private API: +CPython owns one release queue per interpreter lifetime. It accepts +references without an attached thread state through a new private API: ```c typedef enum { @@ -547,12 +624,12 @@ Only `_Py_RELEASE_QUEUED` consumes the reference: the queue takes it over without an incref, and the producer must not also decref it. Failure consumes nothing, raises nothing, and does not inspect the object. -Submission works from any native thread (detached, in another interpreter, +Submission works from any OS thread (detached, in another interpreter, or after `Py_FinalizeEx`). It must not read the object, touch its refcount, call Python, attach, or panic, and it must not wait for the target -interpreter. Allocation must be Python-free: `PyMem_RawMalloc` does not -qualify, because under tracemalloc its hook calls `PyGILState_Ensure`. C -`malloc` and a native mutex do. +interpreter. Allocation must not call Python or attach a thread state: +`PyMem_RawMalloc` does not qualify, because under tracemalloc its hook calls +`PyGILState_Ensure`. C `malloc` and an OS mutex do. A process-lifetime registry maps keys to queue endpoints and survives reinitialization. Lookup, admission, and notification are protected against @@ -569,15 +646,15 @@ calls-to-do bit. GIL builds also check the flag on GIL acquisition. Producers attached to the target set their own eval breaker; others signal its threads under the thread-list lock. -Checkpoint drains are bounded by node count so producers cannot starve the -eval loop. A drain that leaves work, or defers because a context is -available, re-arms the flag. +Drains during eval-breaker handling or GIL acquisition are bounded by node +count so producers cannot starve the eval loop. A drain that leaves work, or +defers because a context is available, re-arms the flag. #### Waking the finalizer -A finalizer parked waiting for guards and leases needs a native wakeup, and -whoever releases the last guard or lease must not touch the interpreter, -which may be freed at once. The wake word and a waiting-finalizer count +A finalizer parked waiting for guards and leases needs a wakeup that does +not require attachment, and whoever releases the last guard or lease must +not touch the interpreter, which may be freed at once. The wake word and a waiting-finalizer count therefore live in process-global storage shared by all finalizers; wakeups for other interpreters are harmless. Wake events are a producer setting `releases_pending`, release of the last Rust lease or C guard, and the @@ -598,16 +675,18 @@ existing bug; see the appendix). #### Draining -Draining requires the exclusive-call protocol, via `ctx.drain_releases()` or -private release authorization between scopes. Checkpoints defer draining -while any context on the thread is available. +Draining requires exclusive context access and suspension via +`ctx.drain_releases()`, or private release authorization between scopes. +Eval-breaker handling and GIL acquisition defer draining while any context +on the thread is available. A consumer that finds work takes one lease and increments its state's protection count for the batch, holding them through decrefs and reentrant callbacks, so shutdown accounts for in-progress batches. It takes no lease for an empty queue, since releasing one would wake the finalizer forever. The -consumer drops native locks before decrefing. Each reference is released -exactly once, attached to its own interpreter; until then it counts as an +consumer releases locks that do not require attachment before decrefing. +Each reference is released exactly once, attached to its own interpreter; +until then it counts as an external strong reference for GC, so moving an edge out of a GC field removes the field's edge before submitting the release. @@ -649,9 +728,10 @@ drain can run and the queue is abandoned at retirement. #### Abandonment On `CLOSED`, `EXPIRED`, or `NO_MEMORY`, `Drop` abandons the reference and -records a Python-free, non-allocating, non-panicking diagnostic. It never -retries in another interpreter. This can leak but never exposes an expired -object. Since safe Rust can forget handles, correctness never depends on +records a diagnostic without calling Python, attaching a thread state, +allocating, or panicking. It never retries in another interpreter. This can +leak but never exposes an expired object. Since safe Rust can forget handles, +correctness never depends on destructors running. Reclaiming every retained `Py` would need revocable ownership records on every handle (see the appendix). @@ -687,14 +767,15 @@ fn checked_size(ctx: &mut ThreadContext<'_>, size: usize) -> PyResult { Immediately before an error-sensitive C call, its wrapper checks for a preexisting exception. If present, it takes ownership and returns an error -without making the call. Audited leaves may preserve the indicator; +without making the call. Audited leaf operations may preserve the indicator; reference release and deallocation follow their save/clear/restore policy. When a C call fails, its wrapper immediately takes the raised exception (with `_PyErr_GetRaisedException`, a pointer transfer) while it still controls the originating state: ```rust -// After exception preflight and the C call, with the exclusive borrow held: +// After checking for a pending exception and making the C call, +// with the exclusive borrow held: if ptr.is_null() { // SAFETY: the audited C API's NULL result guarantees an exception on // this exact, restored state. Capture transfers the raised reference. @@ -706,20 +787,21 @@ if ptr.is_null() { Ambiguous sentinels also check the error indicator. A missing exception becomes `SystemError`. Success with a pending exception discards the result -under the exclusive-call protocol and returns the captured exception. +with exclusive context access and the context suspended, and returns the +captured exception. Failures while creating or reporting an error must leave an appropriate exception such as `MemoryError`. At a generated callback boundary, a pending exception turns `Ok` into `Err`, even if no wrapped operation followed author code. An explicit `Err` remains primary; secondary exceptions are reported as unraisable. `Err` has its key -checked first; a foreign-interpreter error is queued under its own key and -replaced with `SystemError`. Restoring an exception may decref the previous -one, so it uses the exclusive-call protocol. +checked first; an error from another interpreter is queued under its own key +and replaced with `SystemError`. Restoring an exception may decref the previous +one, so it requires exclusive context access and suspension. Context availability proves attachment, not ownership of the error indicator: safe foreign code can set it without crossing a gate. The -preflight and capture rules keep stale errors out of later C calls. +initial checks and capture rules keep stale errors out of later C calls. Function entries return unexpected pending exceptions before invoking author code; `tp_dealloc` and module free save and restore them (see [Dropping](#dropping)). @@ -732,9 +814,8 @@ it neither replaces the original panic nor unwinds itself. The built-in exception marker trait is sealed because its type pointer goes straight to C. Custom exception types stored as `Py` need checked -binding. Converting native failures into a `PyErr` requires an explicit +binding. Converting Rust error values into a `PyErr` requires an explicit mutable context; detach/interop exception variants already own a `PyErr`. -Nothing consults ambient attachment. ### Concrete objects and conversions @@ -796,8 +877,8 @@ arguments cost no refcount; a reference `'view` into Python storage requires continuous attachment. Exact, published `PyBytes` storage is immutable and safe to read while -detached, so its accessor is a leaf bounded by owner and entry rather than -by the context borrow: +detached, so its accessor is a leaf operation bounded by owner and entry +rather than by the context borrow: ```rust impl<'a, 'py> Borrowed<'a, 'py, PyBytes> { @@ -994,12 +1075,12 @@ must provide the same exclusion. #### State lifecycle CPython creates the module and binds its functions before allocating state; -`PyModule_Exec` then allocates zeroed `StateStorage` (key, atomic phase, -`MaybeUninit`). An unexecuted module is reachable, from +`PyModule_Exec` then allocates zeroed `StateStorage` (key, atomic lifecycle +state, `MaybeUninit`). An unexecuted module is reachable, from `importlib.util.module_from_spec` or `sys.modules` mid-import, so every callback handles missing storage. The macro rejects over-aligned types. -| Operation | Allowed source phase | Published phase and access | +| Operation | Allowed source state | Published state and access | | --- | --- | --- | | Any callback before exec | No storage | Functions and methods raise `SystemError("module is not initialized")`; traverse, clear, and free do nothing. | | Claim exec | `Uninitialized` | `Initializing`; any other source raises `SystemError`. | @@ -1020,7 +1101,7 @@ type ModuleExec = for<'a, 'py> fn( ``` Publishing stores use release ordering and reads use acquire. Guards apply -failure phases on unwind, and a panicking destructor is never retried. +failure states on unwind, and a panicking destructor is never retried. Callbacks see state only once `Ready`, since exec may still be populating it. @@ -1028,7 +1109,7 @@ it. Generated modules declare `Py_MOD_GIL_NOT_USED`, `Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`, the internal exact-version ABI, and -the build mode. That requires auditing native dependencies and global state. +the build mode. That requires auditing extension dependencies and global state. Process-global Rust caches may hold only Rust data or checked opaque handles. The identity and lifetime checks apply even when import checks are disabled. @@ -1115,13 +1196,13 @@ outer catch is a last-resort abort. | --- | --- | | Function, method, getter, setter, and constructor | Set a fixed `SystemError` and return the ABI's `NULL` or `-1` error sentinel. | | Module create | Set a fixed `SystemError` and return `NULL`. | -| Module exec | Commit the appropriate failed state phase, set a fixed `SystemError`, and return `-1`. | +| Module exec | Commit the appropriate failure state, set a fixed `SystemError`, and return `-1`. | | Module or instance clear | Finish the lifecycle transition and set `SystemError`, which CPython reports as unraisable. Instance clear returns failure. Module clear returns 0, because CPython skips clearing the module dict when `m_clear` fails. | | Traverse | Preserve incoming exception state and propagate the visitor's exact nonzero return and any error it sets. A Rust panic is fatal because continuing with an incomplete reachability report can violate GC invariants. | | `tp_dealloc` and module free | Save and clear any pending exception before cleanup. Run mandatory non-panicking cleanup guards, then report the panic as unraisable when an attached state is available. Restore the saved exception, or none, afterwards. Abort if cleanup itself cannot be completed safely. | Before dropping a payload or module state, deallocation commits the -destroyed phase and installs cleanup guards, so a panic cannot cause a +destroyed state and installs cleanup guards, so a panic cannot cause a double drop or skip cleanup. Destructors run with the context suspended and receive no context. During finalization, suspension keeps cleanup restrictions in force. @@ -1204,7 +1285,7 @@ types with the same token. There is no global type cache. `type_object.new_instance(ctx, payload)` validates the token and every payload slot's key before publishing. It allocates with `_PyType_AllocNoTrack` (`PyType_GenericAlloc` tracks too early), writes the -payload and phase metadata, then calls `PyObject_GC_Track`. On failure or +payload and lifecycle metadata, then calls `PyObject_GC_Track`. On failure or unwind, the unpublished payload is dropped with the context suspended. Methods recover the defining module through `METH_METHOD`, validate its @@ -1289,14 +1370,14 @@ Argument errors raise `TypeError`. CI runs `cargo fmt` and [Clippy](https://github.com/rust-lang/rust-clippy), and checks that every Python-touching safe `c_api` wrapper takes a context -or sealed capability, and that shared-context wrappers have an audited leaf -contract. +or sealed capability, and that shared-context wrappers are audited as leaf +operations. A new `_testrustapi` module, run by the regular test suite, covers testing the new Rust API correctness. ### Performance -Rust code must match or be very close to C code in performance. Rust APIs should be measured against equivalent C: leaf access, binding, entry (outermost and +Rust code must match or be very close to C code in performance. Rust APIs should be measured against equivalent C: leaf operations, binding, entry (outermost and nested), suspension, slot access, explicit decref, inline drops, queue overflow, contention, tiny callbacks, compression throughput, retained memory, gate overhead on code that never enters Rust, detached versus From c55d46115ef5ee785e946f16ef656be640b8b9ae Mon Sep 17 00:00:00 2001 From: Emma Smith Date: Fri, 16 Oct 2026 16:35:47 -0700 Subject: [PATCH 16/16] Respond to Petr's review --- RUST_API.md | 184 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 126 insertions(+), 58 deletions(-) diff --git a/RUST_API.md b/RUST_API.md index be59d1468ee3c73..c328b1a087df907 100644 --- a/RUST_API.md +++ b/RUST_API.md @@ -110,9 +110,9 @@ between interpreters. Wrappers are added as Rust modules need them. 6. [Exceptions](#exceptions) 7. [Concrete objects and conversions](#concrete-objects-and-conversions) 8. [Modules](#modules) -9. [Functions](#functions) -10. [Classes](#classes) -11. [Argument parsing](#argument-parsing) +9. [Argument parsing](#argument-parsing) +10. [Functions](#functions) +11. [Classes](#classes) ### Bindings and the FFI boundary @@ -814,21 +814,24 @@ it neither replaces the original panic nor unwinds itself. The built-in exception marker trait is sealed because its type pointer goes straight to C. Custom exception types stored as `Py` need checked -binding. Converting Rust error values into a `PyErr` requires an explicit +binding and `PyExceptionClass_Check` before use as exception classes. +Converting Rust error values into a `PyErr` requires an explicit mutable context; detach/interop exception variants already own a `PyErr`. ### Concrete objects and conversions Concrete types such as `PyBytes` and `PyModule` are transparent markers with type-specific methods on `Bound<'py, Marker>`. The set grows with module -needs. `Bound` can add values and types, and -`PyType::new_exception` creates a heap exception type. +needs. `Bound` can add values and types. -`PyType::new_exception` shows why a C function's nominal result type is not -proof: `PyErr_NewException` calls `type(name, bases, dict)`, whose metaclass -can return any object. The wrapper checks the result is a type. The helper -that types a C result is `unsafe`, and Python-choosable results use a checked -downcast. +`PyBaseException::new_exception_type` calls `PyErr_NewException` and returns +`PyResult>`. The C function can invoke a custom metaclass +and does not validate its result. The wrapper uses +[`PyExceptionClass_Check`](https://docs.python.org/3/c-api/exceptions.html#c.PyExceptionClass_Check) +to require `BaseException` or a subclass, returning `TypeError` otherwise. +Checking only that the result is a type is insufficient. The helper that +converts unchecked C results to typed handles is `unsafe`; results chosen +by Python must be validated first. `PyBytes::new_with` lends a zeroed unpublished buffer; `new_with_uninit` skips zeroing: @@ -953,6 +956,24 @@ compile error and prevents merging identical statics. `ModuleDef`, `ModuleRef`, and `ModuleMethodDef` are also invariant in `S` (via `fn(S) -> S`), per the [safety rules](#safety-rules). +#### Module object access + +Functions and methods may request an optional +`module: ModuleRef<'a, 'py, S>` parameter immediately after `mod_state`. +The macro recognizes its type and supplies the module used to obtain +`mod_state`: the function's module or the defining class's module. It is +not a Python argument. Module initialization already receives `ModuleRef`. + +```rust +impl<'a, 'py, S> ModuleRef<'a, 'py, S> { + pub fn as_object(&self) -> Borrowed<'a, 'py, PyModule>; +} +``` + +`module.as_object()` copies the stored borrowed handle without an incref; +its lifetime remains bounded by the module owner and entry scope. This +avoids storing a reference back to the module in its state. + #### Module state Every module has typed state, even if empty: @@ -1114,22 +1135,70 @@ Process-global Rust caches may hold only Rust data or checked opaque handles. The identity and lifetime checks apply even when import checks are disabled. +### Argument parsing + +```rust +#[pyfunction(signature = (data, value = 1, /))] +fn checksum<'a, 'py>( + ctx: &mut ThreadContext<'py>, + mod_state: &ChecksumState, + data: Borrowed<'a, 'py, PyBytes>, + value: u32, +) -> PyResult { + // ... +} +``` + +The wrapper supplies `ctx`, `mod_state`, and any optional `module` parameter; +only the remaining parameters appear in `signature` and are parsed from +Python arguments. Methods also receive `self` and the defining class, `cls`. + +The macro checks names and emits a static `ParamSpec`. Values are extracted +with `FromPyObject`; defaults are Rust expressions. Vectorcall binding uses a +fixed stack array and allocation-free keyword matching, yielding +`Borrowed<'args, 'py, PyAny>` slots with no refcount churn. Constructor +tuple/dict parsing fills the same slots. + +The glue checks its own inputs and keeps every argument alive while it is +in use: + +- A `ParamSpec`/slot-count mismatch is a checked error. +- `kwnames` must be a tuple before its entries are read. +- Constructor keyword values are never borrowed from the `kwargs` dict. + Converting one argument can run Python code, such as `__index__`, that + removes another value from the dict, which would free a borrowed value. + Before converting any argument, the parser takes its own references to + every keyword value, and it keeps them until the call and result + conversion finish. It stores them in a private tuple rather than a list, + because Python code can find a list through `gc.get_objects()` and clear + it. +- Positional arguments and vectorcall arrays can be borrowed without a + copy: the caller keeps them alive for the whole call, and Python code + cannot modify them. + +Argument errors raise `TypeError`. + ### Functions -Python-callable, module-scoped functions are ordinary Rust functions whose first two -parameters are the thread context and module state: +Python-callable, module-scoped functions are ordinary Rust functions whose +first two parameters are `ctx` (the thread context) and `mod_state` (module +state): ```rust +/// Encode data using the standard Base64 alphabet. #[pyfunction(signature = (data, /))] fn standard_b64encode<'a, 'py>( ctx: &mut ThreadContext<'py>, - _state: &Base64State, + _mod_state: &Base64State, data: Borrowed<'a, 'py, PyBytes>, ) -> PyResult> { // ... } ``` +An optional `module` parameter provides +[module object access](#module-object-access). + A body using only leaf operations may take `&ThreadContext`. `#[pyfunction]` emits a `METH_FASTCALL | METH_KEYWORDS` trampoline that enters panic containment and `with_current`, validates the module against its @@ -1141,6 +1210,10 @@ Argument binding, defaults, and the call live in a generated safe function, so author code never compiles under `unsafe`. The macros reject `unsafe`, `async`, `const`, `extern`, and generic functions. +`#[pyfunction]` uses the Rust function's doc comments (`///` or `#[doc = ...]`) +for the Python `__doc__`, storing their text in `ml_doc`. The same rule +applies to methods. + #### Panic containment The entry guard must outlive panic handling, so the catch sits inside @@ -1290,8 +1363,9 @@ unwind, the unpublished payload is dropped with the context suspended. Methods recover the defining module through `METH_METHOD`, validate its state and key, and bound borrows by owner and entry. They receive `&self`, -never `&mut self`, since methods can be called concurrently on free-threaded -builds; mutable state needs interior mutability. +`cls`, `ctx`, and `mod_state`, as described under [Methods](#methods). +`&self` borrows the instance's Rust payload; concurrent calls on free-threaded +builds require interior mutability rather than `&mut self`. ```rust #[derive(PyClassGc)] @@ -1316,55 +1390,49 @@ link), and non-instantiable unless they define `tp_new`. #### Methods -`#[pymethods]` accepts: - -- `#[pyfunction]`: an instance method with a `PyCMethod` trampoline, taking - `&C::State`; -- `#[getter]` and `#[setter]`: halves of a `ClassGetSetDef`; -- `#[new]`: a `tp_new` trampoline that parses arguments, constructs `C`, and - moves it into a new allocation. - -It emits `C::PY_METHODS`, `C::PY_GETSETS`, and `C::PY_NEW`, installed with -`methods`, `getsets`, and `constructor`. All function-glue rules apply. - -### Argument parsing +Instance methods take `&self`, `cls`, `ctx`, and `mod_state`, followed by +their Python arguments: ```rust -#[pyfunction(signature = (data, value = 1, /))] -fn checksum<'a, 'py>( - ctx: &mut ThreadContext<'py>, - state: &ChecksumState, - data: Borrowed<'a, 'py, PyBytes>, - value: u32, -) -> PyResult { - // ... +#[pymethods] +impl Encoder { + #[pyfunction(signature = (data, /))] + fn encode<'a, 'py>( + &self, + cls: Borrowed<'a, 'py, PyType>, + ctx: &mut ThreadContext<'py>, + mod_state: &ModuleStateType, + data: Borrowed<'a, 'py, PyBytes>, + ) -> PyResult> { + // ... + } } ``` -The macro checks names and emits a static `ParamSpec`. Values are extracted -with `FromPyObject`; defaults are Rust expressions. Vectorcall binding uses a -fixed stack array and allocation-free keyword matching, yielding -`Borrowed<'args, 'py, PyAny>` slots with no refcount churn. Constructor -tuple/dict parsing fills the same slots. - -The glue checks its own inputs and keeps every argument alive while it is -in use: +`cls` is the defining class supplied by `METH_METHOD`; `mod_state` is a +shared borrow of that class's module state (`&C::State`). The wrapper +supplies these parameters before parsing Python arguments. Methods may +also request the optional `module` parameter after `mod_state`. -- A `ParamSpec`/slot-count mismatch is a checked error. -- `kwnames` must be a tuple before its entries are read. -- Constructor keyword values are never borrowed from the `kwargs` dict. - Converting one argument can run Python code, such as `__index__`, that - removes another value from the dict, which would free a borrowed value. - Before converting any argument, the parser takes its own references to - every keyword value, and it keeps them until the call and result - conversion finish. It stores them in a private tuple rather than a list, - because Python code can find a list through `gc.get_objects()` and clear - it. -- Positional arguments and vectorcall arrays can be borrowed without a - copy: the caller keeps them alive for the whole call, and Python code - cannot modify them. +`#[pymethods]` accepts: -Argument errors raise `TypeError`. +- `#[pyfunction]`: an instance method with a `PyCMethod` trampoline; +- `#[getter]` and `#[setter]`: halves of a `ClassGetSetDef`, using the + same `&self`, `cls`, `ctx`, and `mod_state` prefix. Their glue obtains + `cls` from the instance's type, since generated types are final; +- `#[new]`: a `tp_new` trampoline taking `cls`, `ctx`, and `mod_state` + before Python arguments. It constructs `C` and moves it into a new + allocation. + +The generated setter checks for a `NULL` value (attribute deletion) before +argument conversion or calling the Rust setter. It raises `AttributeError` +and returns `-1`; Python `None` remains an ordinary value. A future +`#[deleter]` could provide a separate Rust callback, dispatched through the +same C `PyGetSetDef.set` slot. + +`#[pymethods]` emits `C::PY_METHODS`, `C::PY_GETSETS`, and `C::PY_NEW`, +installed with `methods`, `getsets`, and `constructor`. All function-glue +rules apply. ## Testing