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

Add destructors for objects #196

Merged
merged 2 commits into from
Aug 5, 2020
Merged

Conversation

jhugman
Copy link
Contributor

@jhugman jhugman commented Aug 3, 2020

Fixes #8.

This PR adds deinit and __del__ calls into a reserved object_free method. (aside: this should be one more example for the need for #10.)

For kotlin, the JVM has a long and storied history of destructor not being the right thing.

Our best hope is for java.lang.ref.Cleaner to be implemented in Java 9 and then adopted by Android.

Our second best hope is for explicit destroy method. Provided here is an abstract class which checks if the object has been destroyed before each call.

@jhugman jhugman changed the base branch from master to main August 3, 2020 16:48
Copy link
Contributor Author

@jhugman jhugman left a comment

Choose a reason for hiding this comment

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

I've left a few questions as a self-review.

Comment on lines +19 to +30
inline fun <T : FFIObject, R> T.use(block: (T) -> R) =
try {
block(this)
} finally {
try {
this.destroy()
} catch (e: Throwable) {
// swallow
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We all liked use for testing, so I implemented it here.

Comment on lines 12 to 13
if (!isDestroyed.get()) {
block()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could do better protecting this critical section; not sure it's needed right now.

ffi_func: Default::default(),
},
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🦀 question: Should this be new? By convention, does new have arguments?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this should be new, and it's fine for new to have arguments if they're obviously needed for making an instance.

However, I don't think you'll need this at all if you can avoid creating an API-level Method for this, and use only an FFIFunction in the style of the bytebuffer helpers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added new.

However, avoiding adding an reserved Method meant that I was passing in the namespace into the Object now.

@@ -166,7 +166,7 @@ impl<'ci> ComponentInterface {

pub fn ffi_string_free(&self) -> FFIFunction {
FFIFunction {
name: format!("{}_string_free", self.namespace()),
name: format!("_{}_string_free", self.namespace()),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Giving these prefixes makes it harder to use uniffi to write collisions with other uniffi generated code.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm told that names with a leading underscore are reserved for use by the compiler in C code. I don't have any particular case in mind where this would cause problems for us, but one alternative might be to put the leading underscore after the namespace, like {}__bytebuffer_free.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The extra character needs to be before the namespace, otherwise collisions are possible.

Assuming adding a namespace-trailing underscore, this IDL has two collisions.

namespace {
   void _string_free()
   void Sprite__object_free()
}

class Sprite {

}

I'll change this to something more verbose/less exotic.

uniffi/src/interface/mod.rs Outdated Show resolved Hide resolved
private var handle: AtomicLong = AtomicLong(handle)
{%- for cons in obj.constructors() %}
class {{ obj.name()|class_name_kt }}(
internal val handle: Long
Copy link
Contributor Author

Choose a reason for hiding this comment

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

handle is never going to change, so removed AtomicLong.

Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense to me, but I do wonder why we tend to use AtomicLong for handles in Application Services 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now changed back to AtomicLong, so we can zero out the handle when we're destroying it.

@jhugman jhugman requested review from tarikeshaq and rfk August 3, 2020 20:05
Comment on lines 15 to 22
{% let ffi_free = obj.reserved_object_free().ffi_func() -%}
#[no_mangle]
pub extern "C" fn {{ ffi_free.name() }}(
{%- call rs::arg_list_rs_decl(ffi_free.arguments()) %}) {
log::debug!("{{ ffi_free.name() }}");
let _retval = {{ handle_map }}.delete_u64(handle);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be replaced with

define_handle_map_deleter!({{ handle_map }}, {{ ffi_free.name() }})

once #31 lands and I can cargo cult the how that works better.

Copy link
Collaborator

@rfk rfk left a comment

Choose a reason for hiding this comment

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

Thanks @jhugman! This looks pretty solid on a first pass, I left a couple of initial comments and will come back to this in more detail later.

// This would be a good candidate for isolating in its own ffi-support lib.

abstract class FFIObject {
internal val isDestroyed = AtomicBoolean(false)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Something we've done in the hand-written bindings, was use this.handle == 0 to indicate that the object is dead. Would it make sense to move handle up to a property of this parent class in order to do something like that?

Copy link
Contributor Author

@jhugman jhugman Aug 4, 2020

Choose a reason for hiding this comment

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

Rewritten in terms of AtomicLong.

isDestroyed.set(true)
}

internal inline fun <R> safelyCall(block: () -> R) =
Copy link
Collaborator

Choose a reason for hiding this comment

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

Naming nit: things with "safe" in their name are a personal bugbear of mine because it's often not obvious what "safe" means and they regularly turn out to be less safe than advertised. It's probably not a big deal for an internal helper function like this, but still, is there a more descriptive name?

One possible suggestion: if we moved handle up to be a property of this parent class, you could make this something like callWithHandle. Not sure if that would make the call-side code in each method declaration messier though.

// XXX TODO: destructors or equivalent.
override fun destroy() {
super.destroy()
{% call kt::to_rs_call_with_prefix("this.handle", obj.reserved_object_free()) %}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are we are deliberately calling super.destroy() first so that we "poison" the Kotlin side of the object, as a guard against potential concurrency issues. I expect that the underlying handlemap will prevent concurrency issues in practice, but still, if the ordering here is important it may be worth a comment to that effect.

@@ -6,7 +6,8 @@ def __init__(self, {% call py::arg_list_decl(cons.arguments()) -%}):
self._handle = {% call py::to_rs_call(cons) %}
{%- endfor %}

# XXX TODO: destructors or equivalent.
def __del__(self):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this similarly poison the object on the python side, like we do in Kotlin? IIRC it is possible that someone might subclass one of these and extend __del__ in a way that resurrects the the python-side object after the rust-side destructor has been called. (They really shouldn't do that, but it's possible).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ug. I think I'm going to file a python-only issue to investigate stopping resurrecting python side objects.

@@ -166,7 +166,7 @@ impl<'ci> ComponentInterface {

pub fn ffi_string_free(&self) -> FFIFunction {
FFIFunction {
name: format!("{}_string_free", self.namespace()),
name: format!("_{}_string_free", self.namespace()),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm told that names with a leading underscore are reserved for use by the compiler in C code. I don't have any particular case in mind where this would cause problems for us, but one alternative might be to put the leading underscore after the namespace, like {}__bytebuffer_free.

@@ -533,9 +534,24 @@ pub struct Object {
name: String,
constructors: Vec<Constructor>,
methods: Vec<Method>,
object_free: Method,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this need to be an API-level Method? Naively, it feels like it would suffice to generate just the FFIFunction and rely on the automatically-generated foreign-language code for the API-level definition.

In practice that would look very similar to how bytebuffer_free and friends work on the ComponentInterface:

  • The Object::reserved_object_free method creates a new FFIFunction every time it's called.
  • The Object::iter_ffi_function_definitions method calls Object::reserved_object_free and appends the result to the vec of FFIFunction definitions.
  • For foreign-language templates directly emit an FFI-level call to the function returned by Object::reserved_object_free.

I think that would be all you need, and would simplify some of the changes here.

Copy link
Contributor Author

@jhugman jhugman Aug 4, 2020

Choose a reason for hiding this comment

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

I've moved this as directed here; it did mean touching the foreign-language macro templates for all functions and methods, and adding a method_arguments() method to FFIFunction which elides the first argument, and adds a namespace property for Object.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it now safe to remove this object_free property on the struct?

ffi_func: Default::default(),
},
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this should be new, and it's fine for new to have arguments if they're obviously needed for making an instance.

However, I don't think you'll need this at all if you can avoid creating an API-level Method for this, and use only an FFIFunction in the style of the bytebuffer helpers.

Copy link
Contributor

@tarikeshaq tarikeshaq left a comment

Choose a reason for hiding this comment

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

This mostly looks good to me 😄 dropped a couple of comments I had in mind

private var handle: AtomicLong = AtomicLong(handle)
{%- for cons in obj.constructors() %}
class {{ obj.name()|class_name_kt }}(
internal val handle: Long
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense to me, but I do wonder why we tend to use AtomicLong for handles in Application Services 🤔

internal val isDestroyed = AtomicBoolean(false)

open fun destroy() {
isDestroyed.set(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering if we should throw an error here if the object has already been destroyed. Can't think of a concrete reason why, but one thought I'm having is if someone calls the destroy multiple times, I'm guessing we would give them an error, but is it better to error here? And what kind of error would they get if the call goes through the FFI?

I guess we could do what the other bindings do, would Swift/Python complain if you call the destructor twice? Or would they just pass it into the FFI regardless?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As @rfk says, the ConcurrentHandleMap should handle that for us.

I'm torn here: should we be adding isDestroyed so we can encourage/enable defensive programming? Or should we make it idempotent? Or should we just go with what we've got, and review ergonomics once we've started using it?

Copy link
Contributor

Choose a reason for hiding this comment

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

I like how you've done it in the PR, making destroy idempotent, and throwing if you try to call any methods after destroying the class. It feels like that'll be easier for a-c or whoever to wire it up to Cleaner if they want, without having to do additional state tracking.

@@ -11,3 +11,11 @@ assert( s.getPosition() == Point(1.0, 2.0) )

s.moveBy(Vector(-4.0, 2.0))
assert( s.getPosition() == Point(-3.0, 4.0) )

s.destroy()
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering where we'd put documentation for this, so consumers know they need to call the destroy function to do the deallocation. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh that's a really interesting question.

For now, I've documented in the generated section, however, we do need a better solution for this.

@@ -6,41 +6,47 @@
// If the caller's implementation of the struct does not match with the methods or types specified
// in the IDL, then the rust compiler will complain with a (hopefully at least somewhat helpful!)
// error message when processing this generated code.

{% let handle_map = format!("UNIFFI_HANDLE_MAP_{}", obj.name().to_uppercase()) %}
Copy link
Contributor

Choose a reason for hiding this comment

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

❤️

Comment on lines 419 to 451
pub fn method_arguments(&self) -> Vec<&Argument> {
let args = self.arguments.get(1..self.arguments.len());
match args {
None => Vec::new(),
Some(args) => args.iter().collect(),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
pub fn method_arguments(&self) -> Vec<&Argument> {
let args = self.arguments.get(1..self.arguments.len());
match args {
None => Vec::new(),
Some(args) => args.iter().collect(),
}
}
pub fn method_arguments(&self) -> &[Argument] {
self.arguments.get(1..self.arguments.len()).unwrap_or_default()
}

Super small nit, but maybe we can avoid the copy and match here?

Copy link
Contributor Author

@jhugman jhugman Aug 5, 2020

Choose a reason for hiding this comment

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

@linacambridge Oh that's soo much nicer! Thank you!

pub extern "C" fn {{ ffi_free.name() }}(
{%- call rs::arg_list_rs_decl(ffi_free.arguments()) %}) {
log::debug!("{{ ffi_free.name() }}");
let _retval = {{ handle_map }}.delete_u64(handle);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: let _ = ... or drop(...)?

internal val isDestroyed = AtomicBoolean(false)

open fun destroy() {
isDestroyed.set(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

I like how you've done it in the PR, making destroy idempotent, and throwing if you try to call any methods after destroying the class. It feels like that'll be easier for a-c or whoever to wire it up to Cleaner if they want, without having to do additional state tracking.

Copy link
Contributor

@linabutler linabutler left a comment

Choose a reason for hiding this comment

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

Cool!

Copy link
Collaborator

@rfk rfk left a comment

Choose a reason for hiding this comment

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

This LGTM, thanks for digging in to the FFIFunc/Method changes. I think they will mesh nicely with the more deliberate separation between API-level and FFI-level interfaces that I've been working on over in #192

@@ -548,6 +572,20 @@ impl Object {
self.methods.iter().collect()
}

pub fn ffi_object_free(&self) -> FFIFunction {
FFIFunction {
name: format!("ffi_{}_{}_object_free", self.namespace, self.name),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah yes, I see the trick here with needing more of the global context for the namespace. This seems OK I think, thanks for digging in.

@@ -533,9 +534,24 @@ pub struct Object {
name: String,
constructors: Vec<Constructor>,
methods: Vec<Method>,
object_free: Method,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it now safe to remove this object_free property on the struct?

@jhugman
Copy link
Contributor Author

jhugman commented Aug 5, 2020

#203 and #31 takes the templates in a different direction. :(

@jhugman jhugman force-pushed the jhugman/8-add-destructors branch from 8fbd373 to e90e748 Compare August 5, 2020 16:36
@jhugman jhugman merged commit f727a57 into mozilla:main Aug 5, 2020
@jhugman jhugman deleted the jhugman/8-add-destructors branch August 5, 2020 16:40
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.

Add destructors to Kotlin and Swift
4 participants