Type-Safe API for User-defined RPCs#1535
Type-Safe API for User-defined RPCs#1535ThePoultryMan wants to merge 4 commits intogodot-rust:masterfrom
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.
| self.object.with_object_mut(|object| { | ||
| object.rpc_id(id, stringify!(#rpc_name), ::godot::builtin::vslice![#rpc_self_args]); | ||
| }) | ||
| } |
There was a problem hiding this comment.
These should ideally delegate to (hidden) functions in the library, to keep the number of codegen tokens as low as possible.
Does the evaluation of vslice![#rpc_self_args] necessarily need to happen only here? Or could be the builder just store Vec<Variant> or &[Variant] directly?
There was a problem hiding this comment.
Further, they discard the return type.
According to docs:
May return
@GlobalScope.OKif the call is successful,@GlobalScope.ERR_INVALID_PARAMETERif the arguments passed in the method do not match,@GlobalScope.ERR_UNCONFIGUREDif the node's multiplayer cannot be fetched (such as when the node is not inside the tree),@GlobalScope.ERR_CONNECTION_ERRORif multiplayer's connection is not available.
It would be good if we could have our custom error type as meta::error::RpcError, and then return Result<(), RpcError>. The type doesn't need to be an enum, just something with reasonable Display impls.
All this translation should happen in the library, not in generated code.
There was a problem hiding this comment.
Does the evaluation of
vslice![#rpc_self_args]necessarily need to happen only here? Or could be the builder just storeVec<Variant>or&[Variant]directly?
As of b388168, the builders have named fields with the Variant type, and convert from the raw types into Variant at construction. I personally don't use optionals, so I'm going to confirm my assumptions about them before collapsing the stored parameters into a Vec or slice.
Further, they discard the return type.
Adding to the todo-list
The type doesn't need to be an enum, just something with reasonable Display impls.
What would be the reason to not use an enum? And if we aren't using an enum, what would be used instead? A new-type around an int (RpcError(i32))?
There was a problem hiding this comment.
You're right, maybe in this case it can make sense to have an enum to differentiate the 3 Godot errors, but overall the API should largely follow existing types in meta::error module.
| 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 👍
| /// ..Default::default() // only possible in fn, not in const. | ||
| /// } | ||
| /// } | ||
| /// # fn main() {} |
| // We have to manually add the RPC config in this test. | ||
| RpcConfig::default().configure_node(node.upcast_mut(), "say_hello_world"); | ||
| RpcConfig::default().configure_node(node.upcast_mut(), "say_hello_to"); |
There was a problem hiding this comment.
The comment doesn't say much -- it should elaborate why. Is it for mocking purposes? Functional limitations? Something else?
|
|
||
| assert_eq!(Ok(()), node.rpcs().say_hello_world().call()); | ||
|
|
||
| assert_eq!(Ok(()), node.rpcs().say_hello_to("godot".to_owned()).call()); |
There was a problem hiding this comment.
| assert_eq!(Ok(()), node.rpcs().say_hello_to("godot".to_owned()).call()); | |
| let arg = "godot".to_string(); | |
| assert_eq!(Ok(()), node.rpcs().say_hello_to(arg).call()); |
And then reuse the variable further down.
| .get_main_loop() | ||
| .unwrap() | ||
| .cast::<SceneTree>(); | ||
| scene_tree.set_multiplayer(MultiplayerApi::create_default_interface().as_ref()); |
There was a problem hiding this comment.
I guess MultiplayerApi is needed because of this?
Note: You can only safely use RPCs on clients after you received the MultiplayerAPI.connected_to_server signal from the MultiplayerAPI. You also need to keep track of the connection state, either by the MultiplayerAPI signals like MultiplayerAPI.server_disconnected or by checking (get_multiplayer().peer.get_connection_status() == CONNECTION_CONNECTED).
Because it limits this file to __codegen-full feature, which reduces the number of scenarios where RPCs can be tested. But I guess it's OK?
| let rpc_typed_args: TokenStream = param_idents | ||
| .iter() | ||
| .zip(&rpc.signature_info.param_types) | ||
| .map(|(param_name, param_type)| { | ||
| quote! { | ||
| #param_name: #param_type, | ||
| } | ||
| }) | ||
| .collect(); |
There was a problem hiding this comment.
This seems to be only used in a # expansion within quote!.
As such, the .collect() call is only needed if you run into borrow issues -- otherwise #variable can expand iterators as well.
| // TODO: Support functions with optional parameters | ||
|
|
There was a problem hiding this comment.
Please remove the TODO -- as mentioned, optional parameters aren't something I plan to support right now.
| impl<'c, C> WithUserRpcs<'c, C> for Gd<C> | ||
| where | ||
| C: Inherits<crate::classes::Node> + WithUserRpcs<'c, C>, |
There was a problem hiding this comment.
I wonder, should we make Inherits<Node> a supertrait of WithUserRpcs?
There's no scenario where user-defined RPCs can be used if the class isn't Node based, so this would require fewer bounds, also in generic user code.
| RpcConfig::default().configure_node(node.upcast_mut(), "say_hello_world"); | ||
| RpcConfig::default().configure_node(node.upcast_mut(), "say_hello_to"); | ||
|
|
||
| let mut root = scene_tree.get_root().unwrap(); |
There was a problem hiding this comment.
You can inject this via test argument, see e.g. here:
gdext/itest/rust/src/object_tests/object_test.rs
Lines 871 to 876 in ed460cd
| pub struct RpcBuilder<'c, C: GodotClass> { | ||
| object: UserRpcObject<'c, C>, | ||
| rpc_name: &'c str, | ||
| parameters: Vec<Variant>, |
There was a problem hiding this comment.
Strictly speaking, these are arguments (values passed at the call site), not parameters (declaration-site types/values). Could you rename it?
Also, as written in https://github.com/godot-rust/gdext/pull/1535/changes#r3075179469, you could probably just store a slice here? (Assuming lifetime 'c would work...)
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 🙂
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.