Skip to content

Add PyGcTraversable derive - #6330

Draft
bschoenmaeckers wants to merge 7 commits into
PyO3:mainfrom
bschoenmaeckers:gc-integration
Draft

Add PyGcTraversable derive#6330
bschoenmaeckers wants to merge 7 commits into
PyO3:mainfrom
bschoenmaeckers:gc-integration

Conversation

@bschoenmaeckers

@bschoenmaeckers bschoenmaeckers commented Aug 19, 2026

Copy link
Copy Markdown
Member

This adds a derive for the PyGcTraversable trait. This trait is not wired to the pyclass macro just yet but it can already be used manually by calling it in the __traverse__ & __clear__ methods.

It forces the user to implement PyGcTraversable on all fields or explicit disabling it using #[pyo3(gc = false)]. When setting #[pyo3(gc = false)] on a struct that implements PyGcTraversable will result in a compiler error.

To facilitate a escape hatch to prevent infinite recursion I've added a wrapper type PyGcOpaque that will stop visiting that type. This gives finer control on which parts of a (external) type should be traversed.

ref #5663

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs
visit.call(self)
}

fn clear(&mut self) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't actually clear anything and may cause leaks.

I suppose that it can't really clear itself as Py unsafely assumes that it always references a live python object, should that change?

@Person-93 Person-93 Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AFAICT, the only place where it's currently possible to see a cleared object is in its Drop impl, so maybe we can add an extra safety requirement to PyGcTraversable that implementors can't use Py fields in their Drop impl.

I recall a lua engine that did something like this by having an unsafe marker trait TrustedDrop and their GC trait depended on that.

If you derived their GC trait, it'd generate an empty drop impl by default and impl TrustedDrop as well. There was an option in the derive macro to leave this out and the user would have to unsafely implement the marker trait.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.

How do you know that the Py isn't a reference to a mutable object? Can't it be anything?

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'm unsure if we need to do anything here; my understanding is that as long as we allow the GC to see the full cycle it'll choose the point to call tp_clear to break it. Breaking any one edge in the cycle should be enough to collect it all.

But maybe it's possible to just set self to py.None() in order to be sure that the cycle gets broken? That seems like it could make a lot of clear implementations relatively inefficient, but maybe inefficient clear is a corner case we don't care about.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We should be able to set a Py<T> to None where T == PyAny. We could use TypeId for this. This requires a ’static bound which I think is fine?

@bschoenmaeckers bschoenmaeckers Aug 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.

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.

Ah good point about type confusion, I think it'd be weird to have a special case for PyAny. Probably it's best to not clear and recommend using Option if tight cycles are possible?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.

What would a manual implementation of clear for the Node in my example even look like?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Probably it's best to not clear and recommend using Option if tight cycles are possible?

I suppose that'd be an improvement over the way traversal is handled now.

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do all the containers drop their contained item(s)? Shouldn't they call clear on them?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess this is answered by my other comment about Py's clear being a noop.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess this is answered by my other comment about Py's clear being a noop.

You are right. Clear should break cycles, so the most effected way for a container is to drop all items.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clear should break cycles, so the most effected way for a container is to drop all items.

From cpython docs:

Any non-trivial cleanup should be performed in tp_finalize instead of tp_clear.

Dropping an arbitrary type can do anything. That includes non-trivial things. I think it'd be better to call the contents' implementation of PyGcTraversable::clear

It seems like the difficulty with clearing a Py might be a reason to do it this way, but doing it this way isn't sufficient to prevent leaks from Py instances..

#[pyclass]
#[derive(PyGcTraversable)]
struct Thing {
    oh_no: Py<PyAny>,
}

If a Thing::oh_no is set to a python reference to the Thing, the cycle will be detected, but not cleared.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dropping an arbitrary type can do anything. That includes non-trivial things. I think it'd be better to call the contents' implementation of PyGcTraversable::clear

I don't think this will work, as we have to clear it somewhere. By just calling clear on the inner value wastes a possible break point.

It seems like the difficulty with clearing a Py might be a reason to do it this way, but doing it this way isn't sufficient to prevent leaks from Py instances..

Sorry you are right. Py<T> might hold a cycle. What about setting setting it to None when we have a Py?


CPython docs recommend setting PyObject pointers to NULL.

From the docs

A cleared object is a partially destroyed object; the object is not obligated to satisfy design invariants held during normal use.
....
Implementations of tp_clear should drop the instance’s references to those of its members that may be Python objects, and set its pointers to those members to NULL

Making Drop for Py<T> resilient to NULL pointers may be an option here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Another insight; in most cases clear does not have to be perfect when traverse is implemented correctly. As Python will call clear on all nodes in a circle. So in most cases there will be at least one break point. For your example of just 1 pyo3 class referencing itself this does obviously not work.

I noticed this while looking at pydantic-core's gc integration. They only implement traverse and rely on other types in the cycle to break the loop. Without ever implementing clear on their own types.

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 put similar thoughts in #6330 (comment).

I think Py<T> cannot safely hold NULL without a lot of breakage, so setting to Python None is the only "correct default" we can offer, I think.

Perhaps we could give users a #[pyo3(clear = false)] attribute which would enable them to avoid the overhead of setting to None (if it turns out to be meaningful).

Comment thread src/pyclass/gc.rs
Comment thread src/pyclass/gc.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I notice this doesn't implement it for Mutex or RwLock, is that intentional?

@bschoenmaeckers bschoenmaeckers Aug 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am not 100% sure of the implications, as they may introduce deadlocks. So I left them out for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not 100% sure of the implications, as they may introduce deadlocks. So I left them out for now.

The traverse methods can call try_{lock/read/write} so they won't block. It's incomplete, but better than nothing.

The call methods take &mut self so they can access the data without locking. https://doc.rust-lang.org/std/sync/struct.Mutex.html#method.get_mut

@davidhewitt davidhewitt left a comment

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.

Thanks very much for driving this forward, I've really wanted this but not been able to make progress myself.

Comment thread src/prelude.rs

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.

Do we really want these to be in the prelude?

Comment thread src/internal_tricks.rs

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 think can revert this as it's fixed on main

Comment thread src/pyclass/gc.rs Outdated

// SAFETY: Shared references do not own data; forwarding traversal is correct and
// clear is a no-op because `&T` cannot clear through immutable access.
unsafe impl<T: ?Sized + PyGcTraversable> PyGcTraversable for &T {

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.

Is there value on traversal for references? Python types cannot safely hold these.

Comment thread src/pyclass/gc.rs
///
/// # Safety
///
/// Implementations must not execute arbitrary Python code from `traverse`.

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.

Maybe we should be more direct and say that this includes calling Python::attach.

Comment thread src/pyclass/gc.rs
visit.call(self)
}

fn clear(&mut self) {}

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.

Ah good point about type confusion, I think it'd be weird to have a special case for PyAny. Probably it's best to not clear and recommend using Option if tight cycles are possible?

Err(lookahead.error())
}
}
}

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.

From memory we have to accept and ignore IntoPyObject and FromPyObject derive attributes here (maybe re-emit them)? See #5070

Comment on lines +196 to +211
ensure_spanned!(
options.transparent.is_none(),
options.transparent.span() => "`transparent` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.from_item_all.is_none(),
options.from_item_all.span() => "`from_item_all` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.annotation.is_none(),
options.annotation.span() => "`annotation` is not supported for `#[derive(PyGcTraversable)]`"
);
ensure_spanned!(
options.rename_all.is_none(),
options.rename_all.span() => "`rename_all` is not supported for `#[derive(PyGcTraversable)]`"
);

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 think we have to ignore these without asserting due to conflicts with e.g. FromPyObject / IntoPyObject on the same type (maybe have a test?)

Comment on lines +226 to +229
ensure_spanned!(
!data.variants.is_empty(),
tokens.span() => "cannot derive `PyGcTraversable` for empty enum"
);

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 guess we can derive as a noop?

Comment on lines +244 to +245
match &variant.fields {
Fields::Named(named) => {

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.

Can we maybe unify with structs using the fields here?

Comment thread src/pyclass/gc.rs
Comment on lines +511 to +515
/// Wrapper to explicitly opt out of GC traversal for a type.
///
/// This is useful for intentional recursion breakpoints where traversing a
/// reference would recurse indefinitely. Only use this when the wrapped value
/// is known to be traversed through another path.

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 please add an example doc here to help explain when this is necessary? (For me, as well as users 😂 )

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.

3 participants