Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stop leaking in new_closure #2690

Closed
wants to merge 1 commit into from
Closed

Stop leaking in new_closure #2690

wants to merge 1 commit into from

Conversation

mejrs
Copy link
Member

@mejrs mejrs commented Oct 16, 2022

Continues #2686 (comment)

Previously this leaked memory on every call:

use pyo3::prelude::*;
use pyo3::types::*;

fn main() {
    Python::with_gil(|_py| loop {
        let pool = unsafe { _py.new_pool() };
        let py = pool.python();
        let add_one = |args: &PyTuple, _kwargs: Option<&PyDict>| -> PyResult<_> {
            let i = args.extract::<(i64,)>()?.0;
            Ok(i + 1)
        };
        let closure = PyCFunction::new_closure(add_one, py).unwrap();
    });
}

Now it does no more:

  • The PyMethodDef is cleaned up when the closure gets dropped
  • Any Cstrings are deallocated if necessary

I am not sure what the best next step is. I'm leaning towards an api that takes &'str (and always allocates a CString) and another api taking &'static CStr that does not allocate cstrings.

.as_method_def()
.map_err(|err| PyValueError::new_err(err.0))?;

let def = Box::into_raw(Box::new(def));
Copy link
Member Author

Choose a reason for hiding this comment

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

This still leaks; I haven't looked at what actually calls this function so I'm not sure if that's important.

Copy link
Member

Choose a reason for hiding this comment

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

It's called by the PyCFunction::new and new_with_keywords methods.

I think the only way around this would be to have a way to construct &static ffi::PyMethodDef safely and change the PyCFunction apis to take it (even if it's wrapped up in another type so users can't see it).

Either that, or should they be changed to use a capsule in the same way as PyCFunction::new_closure?

Copy link
Member

Choose a reason for hiding this comment

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

Either that, or should they be changed to use a capsule in the same way as PyCFunction::new_closure?

I think this would be the smallest change to fix this. Everything else could be a follow-up optimization.

(Generally speaking, I think there would be an optimization opportunity by creating a #[pyclass] wrapping a ffi::PyMethodDef instead of using a capsule to avoid one layer of dynamic allocation as ffi::PyMethodDef could be stored inline in the #[pyclass] whereas the capsule is itself limited to a pointer if I understand things correctly.)

Copy link
Member

Choose a reason for hiding this comment

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

While true, we've steered clear of having #[pyclass] types internal to PyO3 because the wouldn't be publicly accessible or re-used between PyO3 modules. Solutions to that seem hard. (Maybe it's not actually a problem though.)

Copy link
Member

@adamreichold adamreichold Oct 18, 2022

Choose a reason for hiding this comment

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

because the wouldn't be publicly accessible

I think that would not be a problem here as this should really be an implementation detail. I suspect having to manually write the #[pyclass] to avoid the macros as it used to be done in rust-numpy would be a bit of a hassle though (but easier from within PyO3 than from within rust-numpy).

re-used between PyO3 modules.

I guess this makes it a trade-off between less indirection but more type objects. There are usually multiple method definitions per extension module, so I suspect this could work out. At least I know of no complaints w.r.t rust-numpy's PySliceContainer which suffers from the same issue.

(This does mean that this could be applied only for "one-way" types though, i.e. when we hand data to Python for taking care of. If we might happen to consume such a type created by a different PyO3-written extension module, this would break.)

Copy link
Member

Choose a reason for hiding this comment

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

Actually can use the #[pyclass(crate = "crate")] to just use the macros I think. However it'd mean this feature wouldn't work if the macros feature was disabled.

Yes, perhaps you are right that in this "internal" case it would be suitable.

let method_def = pymethods::PyMethodDef::cfunction_with_keywords(
"pyo3-closure\0",
pymethods::PyCFunctionWithKeywords(run_closure::<F, R>),
"\0",
Copy link
Member Author

Choose a reason for hiding this comment

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

This should probably be a None

@@ -35,15 +35,45 @@ macro_rules! pyo3_exception {
#[derive(Debug)]
pub(crate) struct NulByteInString(pub(crate) &'static str);

#[derive(Copy, Clone)]
pub(crate) enum MaybeLeaked {
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this actually MaybeOwned::Owned(*mut c_char) because the point of the type is to not leak things?

Also could this also be something like

pub(crate) enum MaybeOwned {
  Static(&'static CStr),
  Owned(CString),
}

or does this violate any aliasing guarantees?

Copy link
Member Author

Choose a reason for hiding this comment

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

I went with my approach over this for some reasons:

  • the type is smaller (doesn't really matter, I suppose)
  • I don't think aliasing CString is a problem in practice but using it would make ClosureDestructor (more of) a self referential struct, so I'd really prefer to use raw pointers
  • it's a lot easier to create a *const c_char at compile time than a &'static CStr (msrv 😭 )

Copy link
Member

@adamreichold adamreichold Oct 17, 2022

Choose a reason for hiding this comment

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

Another option that comes to mind is to not store the Static variant at all (we do not need it for destruction) and use a signature similar to as_method_def above for extract_cstr_or_leak_cstring, e.g. make it return

Result<(*const c_char, ManuallyDrop<Option<CString>>), NulByteInString>

and use Option<CString> inside of PyMethodDefDestructor (which is a sort of "double-option" ATM).

@@ -45,11 +55,12 @@ where
let trap = PanicTrap::new("uncaught panic during drop_closure");
let pool = GILPool::new();
if let Err(payload) = std::panic::catch_unwind(|| {
let boxed_fn: Box<F> = Box::from_raw(ffi::PyCapsule_GetPointer(
let destructor: Box<ClosureDestructor<F>> = Box::from_raw(ffi::PyCapsule_GetPointer(
Copy link
Member

Choose a reason for hiding this comment

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

Would this code benefit from using our generic capsule API?

@mejrs mejrs marked this pull request as draft October 16, 2022 21:14
.map_err(|_| NulByteInString(err_msg))
) -> Result<MaybeLeaked, NulByteInString> {
let bytes = src.as_bytes();
let nul_pos = memchr::memchr(0, bytes);
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to do this ourselves? Can't we keep using CStr::from_bytes_with_nul and CString::new and just wrap the result in MaybeLeaked?

Copy link
Member Author

Choose a reason for hiding this comment

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

Otherwise we need to traverse bytes twice; both of those functions check for internal nul bytes.

Copy link
Member

@adamreichold adamreichold Oct 18, 2022

Choose a reason for hiding this comment

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

I understand that. I would say though that the price of a new dependency (memchr was only a dev-dependency until now) and more unsafe blocks is relatively steep for that. I also suspect that the compiler might just be able to optimize out the redundant checks in the safe alternative.

(As an aside, the change is somewhat orthogonal to the topic of the PR. It might be good to propose it separately to avoid derailing the fix for the memory leak. But then, this is probably not too convincing coming from the one doing the derailing in the first place.)

Copy link
Member

Choose a reason for hiding this comment

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

How about this alternative (pseudocode):

let bytes = src.as_bytes();
if !bytes.is_empty() && bytes[-1] == '\0' {
    CStr::from_bytes_with_nul(bytes)
} else {
    CString::new(bytes)
}

Checking for the trailing nul doesn't need an external dependency. We can then branch on the leak-or-not operation and we know that any errors must be due to internal nuls.

@davidhewitt
Copy link
Member

Sorry didn't mean to force-push here; opened #2842 instead as a simpler alternative inspired by this one.

bors bot added a commit that referenced this pull request Dec 28, 2022
2842: Stop leaking in `new_closure` r=adamreichold a=davidhewitt

This is a rebase of #2690 which simplifies the `MaybeLeaked` abstraction from that PR with just `Cow<'static, CStr>`.

This enabled me to annotate with `FIXME` all the places where we still leak; I wonder if we could potentially use `GILOnceCell` in future and statics to avoid those. As those callsities are in `#[pyclass]` and `#[pyfunction]` these are effectively in statics anyway, but it would be nice to tidy up.


Co-authored-by: David Hewitt <[email protected]>
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