Add PyGcTraversable derive - #6330
Conversation
122ca0f to
06cd6d4
Compare
| visit.call(self) | ||
| } | ||
|
|
||
| fn clear(&mut self) {} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I guess you cannot create loops without a mutable type somewhere in the cycle. So a empty clear is fine here.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
For all other cases not clearing it is probably enough and if it’s not, users could always implement __clear__ manually.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Probably it's best to not clear and recommend using
Optionif tight cycles are possible?
I suppose that'd be an improvement over the way traversal is handled now.
There was a problem hiding this comment.
Why do all the containers drop their contained item(s)? Shouldn't they call clear on them?
There was a problem hiding this comment.
I guess this is answered by my other comment about Py's clear being a noop.
There was a problem hiding this comment.
I guess this is answered by my other comment about
Py'sclearbeing a noop.
You are right. Clear should break cycles, so the most effected way for a container is to drop all items.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
I notice this doesn't implement it for Mutex or RwLock, is that intentional?
There was a problem hiding this comment.
I am not 100% sure of the implications, as they may introduce deadlocks. So I left them out for now.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Thanks very much for driving this forward, I've really wanted this but not been able to make progress myself.
There was a problem hiding this comment.
Do we really want these to be in the prelude?
There was a problem hiding this comment.
I think can revert this as it's fixed on main
|
|
||
| // 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 { |
There was a problem hiding this comment.
Is there value on traversal for references? Python types cannot safely hold these.
| /// | ||
| /// # Safety | ||
| /// | ||
| /// Implementations must not execute arbitrary Python code from `traverse`. |
There was a problem hiding this comment.
Maybe we should be more direct and say that this includes calling Python::attach.
| visit.call(self) | ||
| } | ||
|
|
||
| fn clear(&mut self) {} |
There was a problem hiding this comment.
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()) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
From memory we have to accept and ignore IntoPyObject and FromPyObject derive attributes here (maybe re-emit them)? See #5070
| 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)]`" | ||
| ); |
There was a problem hiding this comment.
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?)
| ensure_spanned!( | ||
| !data.variants.is_empty(), | ||
| tokens.span() => "cannot derive `PyGcTraversable` for empty enum" | ||
| ); |
There was a problem hiding this comment.
I guess we can derive as a noop?
| match &variant.fields { | ||
| Fields::Named(named) => { |
There was a problem hiding this comment.
Can we maybe unify with structs using the fields here?
| /// 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. |
There was a problem hiding this comment.
Could we please add an example doc here to help explain when this is necessary? (For me, as well as users 😂 )
4b4ebc8 to
b99d641
Compare
This adds a derive for the
PyGcTraversabletrait. This trait is not wired to thepyclassmacro just yet but it can already be used manually by calling it in the__traverse__&__clear__methods.It forces the user to implement
PyGcTraversableon all fields or explicit disabling it using#[pyo3(gc = false)]. When setting#[pyo3(gc = false)]on a struct that implementsPyGcTraversablewill result in a compiler error.To facilitate a escape hatch to prevent infinite recursion I've added a wrapper type
PyGcOpaquethat will stop visiting that type. This gives finer control on which parts of a (external) type should be traversed.ref #5663