Type-Safe API for User-defined RPCs#1535
Conversation
|
API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1535 |
There was a problem hiding this comment.
Thanks a lot for your contribution! 👍
This API differs from the one proposed in #1158 (
.rpc()&.rpc_id(id)) for two reasons:
- It brings the API more inline with the signal API.
- It makes it easier to add optional parameters without having to split the api style:
Good design rationale; I think being closer to signals is a plus.
Optional Parameters: The generated builders need to have support for optional parameters added. Currently the builder requires all parameters upfront (which I believe includes optional parameters, though I may be wrong).
Default parameters are something that could maybe be discussed once RPCs work flawlessly otherwise, but I wouldn't say it's a required feature for remote procedure calls, and we'd definitely need strong use cases first.
To keep in mind:
- Neither
#[signal]nor#[func]supports Rust-side defaults, and#[rpc]is even more niche. The only place where we support default arguments are engine methods, and one reason for it is that it's the only way to not have breaking changes when new parameters are added. Users on the other hand are in full control of their RPC methods. - Rust itself doesn't have them either. There's the argument that APIs without default parameters are more verbose, but more robust -- you explicitly state what's being sent over the network, it's harder to have version or server/client discrepancies on default values.
- The
Ex*builders are a pain to maintain; in every single refactoring they've required special treatment, with lifetimes, extra types, scoping, visibility etc. Ex*builders generate considerable amount of code, which is bad for compile times.
But it's nice to keep the design forward-compatible, should we add it one day 🙂
Somehow the rpcs() name feels strange to me; maybe it's the fact that it's only consonants, or that it's "remote procedure calls" rather than "remote procedures", but it's consistent with signals() and I also don't have a better idea... 🤔
| // TODO: respect function visibility when generating builders | ||
| pub fn make_rpc_api(for_class: &Ident, rpcs: Vec<&FuncDefinition>) -> TokenStream { |
There was a problem hiding this comment.
This can actually be quite hard, especially if you also want to take into account struct visibility. I think there are some comments on visibility regarding signals, and there's a questionable implementation via decl-macros now.
Maybe that could be reused, or we can just support a subset of possible visibilities.
Bromeon
left a comment
There was a problem hiding this comment.
Now looking a bit deeper at some technicals.
One thing to note is that with the original design (immediate call instead of terminal .call() / .call_id(id)), we would probably not need to generate intermediate structs, resulting in lower implementation complexity + codegen size. But it does limit some use cases and is slightly different from signals. It's a trade-off we should analyze more carefully...
In Godot, RPCs can be configured to call_local mode. We should probably test this, also regarding re-entrancy (with the &mut self borrow, can we still invoke another method on the same object?).
I'm aware that I'm pointing out lots of issues, and fixing all at once can be a bit overwhelming. Don't worry, I don't expect the initial PR to be perfect in this sense 🙂 it's more so that we can collect potential issues early on, and get a plan to address them, both now and later.
| let mut collection_impl_methods = TokenStream::new(); | ||
| let mut rpc_builders = TokenStream::new(); | ||
| for rpc in rpcs { | ||
| let rpc_name = rpc.rust_ident(); |
There was a problem hiding this comment.
This may already be an existing problem with RPCs, but it looks like this doesn't honor #[func(rename = ...)].
We should use the Godot method name if anyhow possible.
|
Personally, I’d Ike to offer some encouragement and say this is really dope, keep it up! |
2b93266 to
8aa0050
Compare
|
8aa0050 is the biggest change here. It effectively replaces all generics in the generated code with direct references to the class holding the RPCs. It also removes all unnecessary bounds from generics and traits. The only bound kept is for |
|
For reference, as of b388168, this is what the generated code for the 14 RPCs in "itest/rust/src/register_tests/rpc_test.rs" looks like: Detailsuse __RpcTest_rpcs::*;
mod __RpcTest_rpcs {
#![allow(non_camel_case_types)]
use ::godot::obj::{RpcCollection, UserRpcObject};
use super::*;
#[doc(hidden)]
pub struct __RpcTestRpcCollection<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcTestRpcCollection<'c> {
#[must_use]
pub fn default_args(self) -> __RpcBuilder_default_args<'c> {
__RpcBuilder_default_args {
object: self.object,
}
}
#[must_use]
pub fn arg_any_peer(self) -> __RpcBuilder_arg_any_peer<'c> {
__RpcBuilder_arg_any_peer {
object: self.object,
}
}
#[must_use]
pub fn arg_authority(self) -> __RpcBuilder_arg_authority<'c> {
__RpcBuilder_arg_authority {
object: self.object,
}
}
#[must_use]
pub fn arg_reliable(self) -> __RpcBuilder_arg_reliable<'c> {
__RpcBuilder_arg_reliable {
object: self.object,
}
}
#[must_use]
pub fn arg_unreliable(self) -> __RpcBuilder_arg_unreliable<'c> {
__RpcBuilder_arg_unreliable {
object: self.object,
}
}
#[must_use]
pub fn arg_unreliable_ordered(self) -> __RpcBuilder_arg_unreliable_ordered<'c> {
__RpcBuilder_arg_unreliable_ordered {
object: self.object,
}
}
#[must_use]
pub fn arg_call_local(self) -> __RpcBuilder_arg_call_local<'c> {
__RpcBuilder_arg_call_local {
object: self.object,
}
}
#[must_use]
pub fn arg_call_remote(self) -> __RpcBuilder_arg_call_remote<'c> {
__RpcBuilder_arg_call_remote {
object: self.object,
}
}
#[must_use]
pub fn arg_channel(self) -> __RpcBuilder_arg_channel<'c> {
__RpcBuilder_arg_channel {
object: self.object,
}
}
#[must_use]
pub fn all_args(self) -> __RpcBuilder_all_args<'c> {
__RpcBuilder_all_args {
object: self.object,
}
}
#[must_use]
pub fn args_func(self) -> __RpcBuilder_args_func<'c> {
__RpcBuilder_args_func {
object: self.object,
}
}
#[must_use]
pub fn args_func_gd_self(self) -> __RpcBuilder_args_func_gd_self<'c> {
__RpcBuilder_args_func_gd_self {
object: self.object,
}
}
#[must_use]
pub fn arg_config_const(self) -> __RpcBuilder_arg_config_const<'c> {
__RpcBuilder_arg_config_const {
object: self.object,
}
}
#[must_use]
pub fn arg_config_fn(self) -> __RpcBuilder_arg_config_fn<'c> {
__RpcBuilder_arg_config_fn {
object: self.object,
}
}
}
impl<'c> RpcCollection<'c, RpcTest> for __RpcTestRpcCollection<'c> {
fn from_user_rpc_object(object: UserRpcObject<'c, RpcTest>) -> Self {
__RpcTestRpcCollection { object }
}
}
impl<'c> ::godot::obj::WithUserRpcs<'c, RpcTest> for RpcTest
where
RpcTest: ::godot::obj::WithBaseField,
{
type Collection = __RpcTestRpcCollection<'c>;
fn rpcs(&'c mut self) -> Self::Collection {
Self::Collection::from_user_rpc_object(UserRpcObject::Internal(self))
}
}
#[doc(hidden)]
pub struct __RpcBuilder_default_args<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_default_args<'c> {
pub fn call(mut self) {
self.object.call_rpc("default_args", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("default_args", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_any_peer<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_any_peer<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_any_peer", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_any_peer", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_authority<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_authority<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_authority", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_authority", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_reliable<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_reliable<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_reliable", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_reliable", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_unreliable<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_unreliable<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_unreliable", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_unreliable", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_unreliable_ordered<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_unreliable_ordered<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_unreliable_ordered", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_unreliable_ordered", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_call_local<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_call_local<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_call_local", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_call_local", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_call_remote<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_call_remote<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_call_remote", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_call_remote", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_channel<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_channel<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_channel", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_channel", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_all_args<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_all_args<'c> {
pub fn call(mut self) {
self.object.call_rpc("all_args", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("all_args", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_args_func<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_args_func<'c> {
pub fn call(mut self) {
self.object.call_rpc("args_func", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("args_func", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_args_func_gd_self<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_args_func_gd_self<'c> {
pub fn call(mut self) {
self.object.call_rpc("args_func_gd_self", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("args_func_gd_self", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_config_const<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_config_const<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_config_const", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_config_const", id, &[]);
}
}
#[doc(hidden)]
pub struct __RpcBuilder_arg_config_fn<'c> {
object: UserRpcObject<'c, RpcTest>,
}
impl<'c> __RpcBuilder_arg_config_fn<'c> {
pub fn call(mut self) {
self.object.call_rpc("arg_config_fn", &[]);
}
pub fn call_id(mut self, id: i64) {
self.object.call_rpc_id("arg_config_fn", id, &[]);
}
}
}It's a lot. Any ideas on how to reduce it are welcome. About the API design: We could drop the An additional reason to avoid the |
|
Also, I cannot figure out why this test is failing. |
|
I’d like to see https://github.com/godot-rust/demo-projects/tree/master/net-pong (we could also resurrect the old multiplayer-bomber example at some point as well) |
Several options:
Can you try running it locally?
Sounds good! Feel free to open a PR once that happens 🙂 |
b3cbef9 to
bfb9948
Compare
|
bfb9948 is, in my opinion, the best compromise for reducing generated code. Since every builder, for non-optional-containing RPCs, can be represented by a single format, I think it's best we do that. Later down the line, we can extend the generic builder within the codegen for optionals, so we would end up with something like: // where `bar` and `baz` are optional parameters
__RpcBuilder_foo<'c, C: GodotClass> {
base_builder: GenericRpcBuilder<'c, C>,
bar: Option<Variant>,
baz: Option<Variant>,
}This method would allows us to skip unnecessary code generation where it is unneeded, but the caveat is that we are putting more processing into the runtime environment. 9b2099d should fix re-entry issues by using |
Bromeon
left a comment
There was a problem hiding this comment.
Thanks for the updates!
bfb9948 is, in my opinion, the best compromise for reducing generated code. Since every builder, for non-optional-containing RPCs, can be represented by a single format, I think it's best we do that.
Great approach 👍 I like that each RPC mostly boils down to a single method, and we can reuse the call/call_id APIs.
In general, could you keep the naming as close as possible to the existing signal codegen? This makes it easier to see the parallels.
pub trait WithSignals: Inherits<crate::classes::Object> {
type SignalCollection<'c, C>
where
C: WithSignals;
#[doc(hidden)]
type __SignalObj<'c>: SignalObject<'c>;
fn __signals_from_external(external: &Gd<Self>) -> Self::SignalCollection<'_, Self>;
}|
One more thing, could you locate the new RPC-specific types in a Maybe |
9aed2bc to
7a2b25d
Compare
|
ad7f1d7 removes the self_mut
.base_mut()
.clone()
.owned_cast::<Node>()
.expect("This is a bug, please report it.")
.rpc(name, parameters);If my understanding is correct, this (should) never error because the I created a dedicated submodule for the RPC system, like that done in #1537. I split the |
|
For any errors that don't have a dedicated variant in Other than that, we could consider storing some more information within each variant if we want to improve the error messages. |
|
@ThePoultryMan now that v0.5 has been released, we should have a bit more time to delve deeper into RPCs. Some administrative PR tips:
If you need help with any of these, don't hesitate to reach out! |
0ad5c06 to
d60a8f7
Compare
|
The doctest was failing due to rust-lang/rust#130274. I worked around that by adding |
|
So is this ready for review? If yes, please change the PR state 🙂 |
bb313a7 to
34598cb
Compare
Bromeon
left a comment
There was a problem hiding this comment.
Fantastic, this starts to look very nice 👍
34598cb to
e232645
Compare
Bromeon
left a comment
There was a problem hiding this comment.
There are some open comments left -- when addressing these, could you squash the commits? We're getting close to merge 🙂
|
@ThePoultryMan Do you think you'll find time for an update in the near future? 🙂 |
|
Apologies for the lack of updates, I've been busy with school. I'll be able to pick this up again near the end of next week. |
e232645 to
6233fb5
Compare
|
This version contain a breaking change: the The only problem here is that, for values that implement |
Bromeon
left a comment
There was a problem hiding this comment.
We could implement something (maybe a trait?) that doesn't require references for types that implement
Copy, but that might be overengineering a little bit.
Luckily I did this overengineering already a while ago 😀
AsArg does exactly this, and it's in fact what engine APIs and generated signal emit() functions use. You can look at signal's codegen for implementation, or at tests to see how it looks in action:
| /// Holds a mutable reference to the [`GodotClass`] | ||
| Internal(&'c mut C), | ||
| /// Holds a [`Gd`] pointer to the [`GodotClass`] | ||
| External(Gd<C>), |
There was a problem hiding this comment.
Also here, comments are redundant when seeing the declaration. I think it would be more interesting to state when each variant is used, e.g. link to the method that creates it.
My question would again be: does the user ever need to interact with these variants? Would it be better to hide them (either #[doc(hidden)] on variants, which is a bit unusual, or wrap the enum into an opaque struct)?
Nitpick, RustDoc should generally end with a . period.
5d95e47 to
8938d64
Compare
6764d48 to
0482b28
Compare
0482b28 to
ff3e61c
Compare
|
For the test, we do still need to lock it behind Note that, in my testing, registering the RPCs manually will still result in them erroring when |
Edit bromeon: closes #1158
This is a basic implementation of generating a type-safe api for calling RPCs in rust code.
Design
A
rpcs()method is exposed on both the owning class/node and theGd<T>smart pointer to it. Calling this method will build a struct containing aUserRpcObjectenum, and a method that returns a RPC-builder for each user-defined RPC. Calling one of these RPC-builder methods will pass theUserRpcObjectinto a builder. The builder can then be used to call the RPC viacall()orcall_id(i64).UserRpcObjectcontains two variants: 1)Internalcontaining a mutable reference to aGodotClassthat implementsWithBaseFieldand 2)Externalcontaining aGdpointer.Method parameters of the RPC function are passed in the RPC-builder function and stored in the builder until they are used when the RPC is called.
This API differs from the one proposed in #1158 (
.rpc()&.rpc_id(id)) for two reasons:vs.
Regardless, switching to the style proposed in #1158 wouldn't be hard.
Example
Todo (non-exhaustive)
rpc_test.rsacts as an indirect test to see if the generated code compiles, but dedicated test(s) should be added.rpc()/rpc_id()godot callsFuture Works
This implementation is not the final stop. Several more pieces need to be addressed, either in this PR, or future PRs.
In my original, external, implementation of this system, generated documentation included the parameter names & types.