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

JsBox RFC #33

Closed
wants to merge 10 commits into from
Closed

JsBox RFC #33

wants to merge 10 commits into from

Conversation

kjvalencik
Copy link
Member

@kjvalencik kjvalencik commented Jul 30, 2020

JsBox is a smart pointer to data created in Rust and managed by the V8 garbage collector. JsBox are a basic building block for higher level APIs like neon classes.

fn person_new(mut cx: FunctionContext) -> JsResult<JsBox<Person>> {
    let name = cx.argument::<JsString>(0)?.value(&mut cx);
    let person = Person::new(name);

    Ok(cx.boxed(person))
}

fn person_greet(mut cx: FunctionContext) -> JsResult<JsString> {
    let person = cx.argument::<JsRef<Person>>(0)?;
    let greeting = person.greet();

    Ok(cx.string(greeting))
}

Rendered RFC

Work in progress implementation

@kjvalencik kjvalencik changed the title JsBox RFC JsCell RFC Jul 31, 2020
@kjvalencik kjvalencik changed the title JsCell RFC JsBox RFC Aug 3, 2020
}
```

The instance of `Pool` can be returned to Javascript using a `JsBox`. The `Pool` won't be dropped until the `JsBox` is garbage collected.

This comment was marked as resolved.

}
```

`JsBox` can be passed back and forth between Javascript and Rust like any other Js value.

This comment was marked as resolved.

Example of boilerplate required to use a Neon Class as a thin wrapper:

```rust
pub struct Person {

This comment was marked as resolved.

This comment was marked as resolved.

}
```

Data may only be borrowed immutability. However, `RefCell` can be used to introduce interior mutability with dynamic borrow checking rules:

This comment was marked as resolved.


fn set_pool_size(mut cx: FunctionContext) -> JsResult<JsUndefined> {
let size = cx.argument::<JsNumber>(1)?.value() as u32;
let mut pool = cx.argument::<JsPool<RefCell<Pool>>>(0)?;

This comment was marked as resolved.


#### `Send + 'static`

`JsBox` are passed across threads and therefore must be `Send`. It is *not* necessary for `JsBox` to be `Sync` because they may only be borrowed on the main thread.

This comment was marked as resolved.

This comment was marked as resolved.


*Note*: Passing an external created by another library, potentially even another neon library, is _undefined behavior_.

Progress is being made to add a [tagging feature](https://github.com/nodejs/node/pull/28237) to more safely unwrap externals. It should be incorporated into future designs; however, it is not included in this proposal.

This comment was marked as resolved.

This comment was marked as resolved.


##### Rust

```rust

This comment was marked as resolved.

}
```

This approach has several significant drawbacks:
Copy link
Contributor

@dherman dherman Aug 10, 2020

Choose a reason for hiding this comment

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

This is very compelling! Seeing the contrast and drawbacks really helps justify the design you ended up with.


* `JsExternal`. Matches the name in N-API documentation (external reference); however, `cx.external(v)` might be confusing.
* `JsRef`. Also, matches N-API but, does not match Rust naming conventions.
* `JsCell`. Only appropriate for the `RefCell` approach.
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make sense to consider a high-level convenience type, something like type JsRefCell<T> = JsBox<JsRefCell<T>>;, for the common case of interior mutability? This would make a common case more concise, e.g. in your Pool example above, it would change from

     let mut pool = cx.argument::<JsBox<RefCell<Pool>>>(0)?;

to

     let mut pool = cx.argument::<JsRefCell<Pool>>(0)?;

It would also create an opportunity to document the use case of interior mutability directly in the API docs, making the idiom a little more discoverable.

Maybe not necessary for this RFC, but we could leave a note in "unresolved questions" about future convenience APIs and mention the idea there.

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 added a section about future extension that might include this. In my opinion, this is above and beyond this RFC since in addition to the type alias we would want other helper methods (e.g., cx.ref_cell(..))


* `Buffer` can be mutably borrowed multiple times
* `Lock` does not prevent overlapping memory regions
* Multiple [`Lock`](https://docs.rs/neon/0.4.0/neon/context/struct.Lock.html) can be created allowing aliased mutable borrows
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 still kind of shocked about this soundness hole. I could've sworn I checked for it and panic.

Conceptually, I still find the idea of being able to use RAII and Rust mutability rules to lock the main JS thread to be pretty compelling. But I like how you've at least decoupled that idea from the idea of safely exposing Rust data owned by the JS engine back to Rust.

I wonder if we might still have a place for the Lock API just as a way to have self-documenting critical sections. OTOH, it might end up being a cute idea that's not particularly useful anymore. Curious what you think?

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 don't think the Lock API fits here because nothing is being locked. I don't understand what you mean by RAII to lock the main js thread. The existing API doesn't take any action to lock the the VM.

Copy link
Member Author

@kjvalencik kjvalencik Aug 16, 2020

Choose a reason for hiding this comment

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

@dherman I thought about this some more and it finally clicked. I think the Lock guard API only makes sense for accessing data from Buffer.

When accessing a Buffer, Neon needs to uphold Rusts guarantees on ownership and mutability of the slice. However, since the data is not owned by Rust, there may exist references unknown to Rust.

For example, imagine a scenario where a user borrows a &[u8] to the Buffer. According to Rust invariants, this data will not change during the lifetime of the borrow. However, if JavaScript is invoked, e.g. by calling a JsFunction, the data could be mutated, violating the invariant.

The Lock API solves this by holding an exclusive reference to Context. The Lock statically prevents calling any Neon methods that could potentially mutate the slice.

This problem does not exist for JsBox. The data is owned by Rust and can't be mutated by pure JavaScript. At best, the user could invoke JavaScript that calls another Neon method. However, that Neon method would still be bound by Rusts dynamic borrow checking (RefCell). It's simpler, more ergonomic, and no less safe to return shared references without a Lock/Guard on JsBox.

Lastly, there is an edge case where the Lock API might not uphold invariants. If the Buffer was created as an external buffer (memory created and managed externally), it's possible for other non-Neon native code to mutate these types of buffers. Neon could use n-api methods for detecting externals and refuse to lend them. Additionally, neon could provide unsafe fn borrow_unchecked methods for reading data from externals.

Copy link
Contributor

Choose a reason for hiding this comment

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

It took me a couple of reads but I think you've really nailed the difference:

  • Rust data (JsBox) can be protected by Rust's Cell invariants, so we don't need to worry about reborrows via VM re-entrancy;
  • C/C++ data (JsArrayBuffer) can't be protected by Rust's Cell invariants, so we do need to worry about reborrows via VM re-entrancy.

So if I understand you, you're saying the Lock API makes sense to keep, but specifically and only for typed arrays.

On the one hand, it's nice that we can borrow a JsBox more ergonomically. On the other, the asymmetry is a little frustrating. It's a shame there's no way (AFAIK) to dynamically borrow an ArrayBuffer, which we could then use to make this safe without having to lock the JS VM. You can permanently detach its backing buffer, but that doesn't help this use case; we'd need a temporary, reversible version of detaching.

The Lock statically prevents calling any Neon methods that could potentially mutate the slice.

Exactly! This is definitely subtle (if ingenious—thank Niko for the idea), and is probably worth some better explanation in docs, assuming we keep the API.

If we only end up needing it for ArrayBuffer and not JsBox, I wonder if we want a less general name than Borrow, especially since JsBox doesn't use it. Maybe something that makes more explicit that it's a stop-the-world borrow, so the connection to Lock is clearer?

Lastly, there is an edge case where the Lock API might not uphold invariants. If the Buffer was created as an external buffer (memory created and managed externally), it's possible for other non-Neon native code to mutate these types of buffers. Neon could use n-api methods for detecting externals and refuse to lend them. Additionally, neon could provide unsafe fn borrow_unchecked methods for reading data from externals.

Good catch. That makes sense to me.

So, some of this stuff is out of scope for this RFC, but probably worth summarizing the issues in the "open questions" section so we can capture them for subsequent work.

Copy link
Member Author

Choose a reason for hiding this comment

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

@dherman I like the clear naming of borrow for the API. However, we could move it from neon::borrow to neon::binary to clarify the relationship to JsArrayBuffer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Love this idea!

# Unresolved questions
[unresolved]: #unresolved-questions

- Should we implement this for the legacy backend? We likely will not since this is a brand new API.

This comment was marked as resolved.

[unresolved]: #unresolved-questions

- Should we implement this for the legacy backend? We likely will not since this is a brand new API.
- Is it acceptable to let `JsBox::new` panic since it only fails when throwing or shutting down?
Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, these are both highly rare cases, which is the spirit of panic.

Copy link
Member Author

Choose a reason for hiding this comment

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

Currently the RFC defines the APIs as returning a JsResult, but that's inconsistent with other methods. I think that should be changed to return an unwrapped JsBox<_>.

If we ever decide in the future that we want this potential failure exposed directly, it would be best done all at once for consitency.

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel comfortable making all the APIs return an unwrapped JsBox<_>. NeonResult is meant to represent whether the operation caused a JS exception. Failing because it was already in a throwing state is basically a programmer error that we happen not to be able to enforce through the type system and therefore a panic; the VM shutting down is extremely rare and unrelated to triggering a JS exception.


The `JsBox` API is not entirely safe. It relies on the user only passing N-API externals created within that Neon library. Passing an external created by another library, potentially even another neon library, is _undefined behavior_.

It is *not* undefined behavior to attempt a value that is not an external as a `JsBox`. This will fail predictably. It **is** undefined behavior to downcast a non-`JsBox` externally because the pointer will be treated as if it is one.

This comment was marked as resolved.

text/0000-jsbox.md Outdated Show resolved Hide resolved
@kjvalencik kjvalencik added the final comment period last opportunity to discuss the proposed conclusion label Aug 26, 2020
@kjvalencik
Copy link
Member Author

This RFC has entered the final comment period.

@kjvalencik
Copy link
Member Author

kjvalencik commented Aug 28, 2020

Future Expansion

Background

While exploring the Persistent RFC, an alternate design that requires manual drops was proposed. Manual drops are very simple for single use Persistent, but can become complicated when Persistent are cloned.

Possible Solution

JsBox could support an optional finalizer that is called on the main JavaScript thread prior to the Rust data being dropped. Since in most cases multi-use Persistent will be owned by a JsBox, it provides an opportunity to perform a manual drop.

API

pub trait Finalize {
    fn finalize(self, cx: FinalizeContext);
}

impl<T: Send + 'static> JsBox<T> {
    pub fn with_finalizer<'a, C>(cx: &mut C, value: T) -> Handle<'a, JsBox<T>>
    where
        C: Context<'a>,
        T: Finalize;
}

An additional JsBox constructor is added that accepts a T: Finalize and calls the finalize method prior to dropping.

Following idiomatic patterns establish elsewhere, Persistent would implement a Drop trait that panics to prevent user error.

impl<T> Drop for Persistent<T> {
    fn drop(&mut self) {
       panic!("Persistent must be manually dropped.");
    }
}

Alternative

An alternative design is to provide the finalize method as an argument to the constructor instead of as a trait.

impl<T: Send + 'static> JsBox<T> {
    pub fn with_finalizer<'a, C>(
        cx: &mut C,
        value: T,
        finalizer: fn(FinalizerContext, T),
    ) -> Handle<'a, JsBox<T>>
    where
        C: Context<'a>,
}

The advantage of the trait approach is that it co-locates the finalizer with the data. The disadvantage is that Neon would not prevent a user from defining a trait, but failing to use the with_finalizer constructor.

In my opinion the trait approach is preferred and the ergonomics could be improved in and when the Rust specialization RFC is implemented.

Example

struct MyServer {
    server: Server,
    // Wrapping the `Persistent` in a `ManuallyDrop` provides two advantages:
    // 1. Immediately signals that something in this struct needs special attention
    // 2. If a manual `drop` is missed, it causes a leak instead of a `panic`
    callback: ManuallyDrop<Persistent<JsFunction>>,
}

impl Finalize for MyServer {
    fn finalize(self, cx: FinalizeContext) {
        self.callback.drop(&mut cx);
    }
}

fn create_server(cx: FunctionContext) -> JsResult<JsBox<MyServer>> {
    let callback = cx.argument::<JsFunction>(0)?.persistent(&mut cx);
    let server = MyServer {
        server: Server::new(),
        callback: ManuallyDrop::new(callback),
    };

    Ok(JsBox::with_finalizer(server))
}

Copy link
Contributor

@dherman dherman left a comment

Choose a reason for hiding this comment

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

The changes look good to me! Just one FIXME to update.

text/0000-jsbox.md Outdated Show resolved Hide resolved
@kjvalencik
Copy link
Member Author

Merged in 951e625

@kjvalencik kjvalencik closed this Sep 18, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
final comment period last opportunity to discuss the proposed conclusion
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants