Skip to content

Add initial draft of Rust API design document - #45

Open
emmatyping wants to merge 1 commit into
3.x-rust-in-cpythonfrom
emmatyping/rust-api-design-doc
Open

Add initial draft of Rust API design document#45
emmatyping wants to merge 1 commit into
3.x-rust-in-cpythonfrom
emmatyping/rust-api-design-doc

Conversation

@emmatyping

@emmatyping emmatyping commented Aug 19, 2026

Copy link
Copy Markdown

Okay, it took a while to play with these API design ideas and get them to a state where I was happy with them. I have a rough prototype similar to #41 that is based on this, but I figured it would make more sense to discuss the API design and make sure I haven't missed things or there isn't unsoundness in this design. It turns out it is just darn difficult to get thread-state passing right when you consider re-entrancy and multi-threading.

As mentioned in the document, my overall priorities in this design were:

  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.

I think 3 is probably the furthest from achievable with this current design, we query the TLS quite frequently with this design, but I believe a lot of those queries could be cleaned up. 4 is really a matter of taste, but I tried to be somewhat similar while also trying to have as small an API surface as possible.

Things left undone in this design that we will need to figure out:

  • Subinterpreter support (I expect this will best be discussed at the core sprints in October, but if anyone has thoughts, please chime in!)
  • Optimizing/handling Drop properly, including if one occurs while detached.
  • A buffer protocol wrapper (cc @ngoldbaum, I know you have Thoughts about this :) )
  • probably things I'm forgetting!

I also wasn't totally sure where to put this, perhaps it would go better in InternalDocs?

AI disclosure: I worked with GPT 5.6 Sol when iterating on this API and the content is a mix of writing me and the LLM. I've reviewed every word, but please let me know if there is anything unclear!

Comment thread RUST_API.md

- **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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

For a safe buffer protocol, I'm thinking for now we could ensure the buffer is uniquely held, and copy it if it isn't? There are usability and performance implications of that however.

Comment thread RUST_API.md
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

For this I am thinking it would make the most sense to either use PyMutex and wrap that, or write a lock which cooperates with the interpreter like the PyOnceLock in PyO3.

@emmatyping
emmatyping force-pushed the emmatyping/rust-api-design-doc branch from b334292 to 4a6e800 Compare August 24, 2026 18:37

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Did a read-through and had a few comments questions and suggestions

Comment thread RUST_API.md
pub type PyResult<T> = Result<T, PyErrRaised>;
```

`PyErrRaised` is `!Send` and `#[must_use]`. Built-in and custom exception types

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe worth an external link for must_use? I’d never seen it before this and had to look up the docs: https://doc.rust-lang.org/std/attribute.must_use.html

Comment thread RUST_API.md

Generic implementations compose. `Option<T>` maps `None`, borrowed handles can
be promoted with an incref, and an existing `Bound<T>` can be returned without
refcount traffic:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: churn instead of traffic?

Comment thread RUST_API.md
```

Only generated `ModuleDef<S>` glue constructs `ModuleRef<S>` and proves the
state type.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

not quite sure what “proves the static type” means here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Probably a mis-edit on my part, I'll fix that!

Comment thread RUST_API.md
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is lack of subinterpreter support a problem for the PoC? Or would we degrade to a pure-python library instead of using a Rust accelerator under subinterpreters?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I expect it will be. There's been a lot of effort to have the stdlib support subinterpreters. I'm not sure if we would be able to degrade gracefully.

It would be useful to have some solution, but I'm okay punting until the core sprints.

Comment thread RUST_API.md
pub struct ThreadContext<'py> {
ptr: NonNull<ffi::PyThreadState>,
detach: Option<DetachPermit<'py>>,
#[allow(clippy::type_complexity)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It's better to use expect where possible and to provide a reason as well:

Suggested change
#[allow(clippy::type_complexity)]
#[expect(clippy::type_complexity, reason = "...")]

maybe it's a bit much for a documentation 🤷

Comment thread RUST_API.md
```rust
#[derive(PyClassGc)]
struct Node {
parent: Option<Py<PyInstance<Node>>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
parent: Option<Py<PyInstance<Node>>>,
parent: Option<Py<PyInstance<Self>>>,

@emmatyping emmatyping mentioned this pull request Aug 30, 2026
Comment thread RUST_API.md
Comment on lines +685 to +686
`CallbackArgs` lends `'args`, while `with_current` supplies `'py`. Constructor
tuple/dict parsing fills the same slots and shares conversion code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Converting one argument can run Python code that removes another value from kwargs, if the parser only borrowed that value it could then access freed memory.

So, the constructor parser must take its own references to keyword values before converting any arguments, and keep them until the call and result conversion finish.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Filled a bug report for PyO3: PyO3/pyo3#6392
@davidhewitt FYI

Comment thread RUST_API.md

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suppose it could lead to memory leak.

Let's imagine next:

#[derive(PyClassGc)]
struct Encoder {
    level: u32
}

#[derive(ModuleStateGc)]
struct ExampleState {
    encoder: Py<PyInstance<Encoder>>
}

These objects form a cycle module -> state.encoder -> Python encoder instance -> Python encoder type -> module (again!).

Comment thread RUST_API.md
Comment on lines +253 to +258
`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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These checks confirm that an exception is pending, but cannot detect whether a later error replaced the original one. Returning an older PyErrRaised can therefore propagate a different exception.

Comment thread RUST_API.md
| 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. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we explicitly require preserving any pending Python exception during destruction? If Drop panics, reporting that panic as unraisable can overwrite and clear the error. We should save and clear the exception before cleanup, then restore it after cleanup and reporting, including when the original state was empty.

Comment thread RUST_API.md
Comment on lines +617 to +622
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It may be out of scope for now, but I think we need to define locking protocol for zlib. AFAIK streaming zlib holds its lock across detaching and reattaching, so another thread waiting on an ordinary Mutex while attached can deadlock with the GIL or free-threaded GC.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants