-
-
Notifications
You must be signed in to change notification settings - Fork 227
Allow disconnection of type-safe signals #1198
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
base: master
Are you sure you want to change the base?
Allow disconnection of type-safe signals #1198
Conversation
Thanks!
(Personally I'm cool with |
API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1198 |
53f6ff9
to
20084ba
Compare
Woops, sorry about that. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you, this is a great addition! 👍
Thanks also for the thoroughly testing. However, I feel like 400 LoC to test disconnection is a bit much. While it's worthwhile to have good test coverage, we also need to keep in mind:
- All code needs to be maintained, including tests.
- Every
#[itest]
and registered#[derive(GodotClass)]
increases the time it takes to run all integration tests.
The effects are small, but they add up, so we try to keep things at a balance 🙂
Some ideas where I see potential to reduce the amount of test code:
- Extract common code patterns (just refactoring, I made a comment inline).
- Do not retest other interactions that are known to work
- Example of this is
DisconnectOnDropNode
. We test elsewhere thatDrop
is invoked when a node is freed, and we know that RustDrop
generally works. So I don't really see the added value over a simpler procedural test that explicitly destroys connections at the call site. This would also result in simpler flow. - Same with having 10 nodes and then doing modulo to pick some. Just declare two nodes, one with and one without the feature to test.
- Example of this is
- Could you combine
disconnect_other_builder
anddisconnect_self_builder
into one? I feel like the handler could just ignore one of the nodes, no?
Maybe you have other ideas? 🙂
/// Disconnects the signal from the connected callable. | ||
/// | ||
/// # Panics | ||
/// If the connection does not exists. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// If the connection does not exists. | |
/// If the connection does not exist. |
let mut signal_node = SignalNode::new_alloc(); | ||
tree.add_child(&signal_node); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think you need to add the node to the tree here, do you? This was done in some tests because Godot's own lifecycle signals were tested. Here, you have a custom signal notify
however.
By the way, can you rename that signal to something like my_signal
? The name notify
is unfortunate as it looks a bit like Object::notify
and the whole notification mechanism.
.get(0) | ||
.unwrap(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.get(0) | |
.unwrap(); | |
.at(0); |
Thank you - will take a look at this and adapt the code.
Absolutely - idea was to ensure that every variation was working in every circumstance, but in hindsight I can see that it probably became much larger than it needs to be. Will try to merge the tests and remove any redundant checks. Should I make a new commit for the new changes or squash them with this one? |
You can squash the commits 🙂 (Force-pushes still show up in GitHub PRs, so one can still inspect the diff). |
&self, | ||
callable: &Callable, | ||
flags: Option<ConnectFlags>, | ||
) -> (Gd<Object>, &str) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silly question, but can't we just return ConnectHandle here? CC: @Bromeon
In all the cases we use returned values only to create the ConnectHandle (ConnectHandle::new(owned_object, callable, signal_name.into())
). Function signatures already tell what is being returned, and we are not going to use these values in any other way. (i.e. – it makes code a little more noisy and repetitive for no benefit, which is no biggie tbh)
(Not a fan of Into by the way 😅. I was pretty sure it was a StringName! Ok, that's a bigger offender)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense!
Also agree with into()
, you could write this instead -- combined with the other change from GString
to StringName
:
StringName::from(signal_name)
20084ba
to
235c226
Compare
Made some changes - most notably refactoring the tests and making the pure The handle now uses a Cow for the signal name. Also note that I also changed the signature of |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the refactors, great improvement! 200 LoC looks already much better 👍
One question, could you move the new tests to their own file signal_disconnect_test.rs
? It would save one level of indentation, and you can also use an inner attribute #![cfg(...)]
.
/// Disconnects the signal from the connected callable. | ||
/// | ||
/// Generates an error if the connection does not exist. | ||
/// Use [`is_connected()`][Self::is_connected] to make sure the connection exists. | ||
pub fn disconnect(mut self) { | ||
self.receiver_object | ||
.disconnect(&*self.signal_name, &self.callable); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Godot errors are not that useful in Rust -- there's no way to programmatically handle them, and they can easily be overlooked. Since this is a higher-level API than Object.disconnect
and Signal.disconnect
, it would be good to make potential logic errors abundantly clear to the user.
We should probably panic in Debug mode:
/// Disconnects the signal from the connected callable. | |
/// | |
/// Generates an error if the connection does not exist. | |
/// Use [`is_connected()`][Self::is_connected] to make sure the connection exists. | |
pub fn disconnect(mut self) { | |
self.receiver_object | |
.disconnect(&*self.signal_name, &self.callable); | |
} | |
/// Disconnects the signal from the connected callable. | |
/// | |
/// # Panics (Debug) | |
/// If the signal is no longer connected. Use [`is_connected()`][Self::is_connected] to make sure the connection exists. | |
pub fn disconnect(mut self) { | |
debug_assert!(self.is_connected()); | |
self.receiver_object | |
.disconnect(&*self.signal_name, &self.callable); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this deserves some discussion, how do you typically disconnect signals @andersgaustad @Yarwin @TitanNano? Are the connections usually valid beforehand?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As someone who has been eagerly awaiting type-safe signal disconnection, allow my two cents about this one.
In my current use-case, a single-player project of a smaller scale, every object is responsible for connecting to/disconnecting from the signals it needs based on game state/phase/whatnot changes, and in absolute majority of cases the connection is valid and if it's not, I probably messed something up.
That being said, I can see how in a bigger project where a lot of objects get constantly created/freed, keeping track of things might be more problematic. I was wondering if type-safe signal disconnection would include any sort of safety checks or if users would have to do it explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the input. So my suggested approach with panics would indeed reveal logic errors in your case, and shouldn't happen in exported builds. (I'm also tending to have fewer panics in Release, because they are usually quite player-hostile, and a no-op disconnect isn't fatal)...
Note that it's always possible to use
if handle.is_connected() {
handle.disconnect();
}
as a pattern for fallible disconnect.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea.
expect_panic("disconnect invalid handle", || {
connect_handle.disconnect();
});
Should maybe restore this test as well.
Do we expect to always run the tests in Debug?
If not, should we surround the expect_panic
in a if cfg!(debug_assertions) { ... }
or something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is expect_debug_panic_or_release_ok
for this purpose 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Connections have to have been valid at some point, but this is already guaranteed by the presence of the ConnnectHandle
so I wouldn't touch the Godot behavior. If someone disconnects the same signal twice, they'll get an error log from the engine, but otherwise it should be fine. I don't see why we should replace that with our own panic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see why we should replace that with our own panic.
Like @brezhnevtusks mentioned: it immediately reveals logic errors.
Godot is known to be spammy with errors + warnings, and unfortunately some of them are meaningless or not actionable. Just look at our dodge-the-creeps CI, I don't remember a time when there were zero UID warnings on all platforms, and I've updated UIDs multiple times. Probably some tiny misconfiguration, but it works anyway. Point being, this has unfortunately led to a culture where errors are often ignored, plus it's easy to miss them when not looking at the console. It's not just possible, but extremely likely to miss them in CI.
In godot-rust, generated class APIs generally work with printed errors (because it would be monumental to translate all to panics, and often not desired), but hand-written APIs almost always use Result
(if recoverable) or panic (if logic error). For builtin types, I went the extra mile to make error handling idiomatic in Rust: 🙂
(The reason why panic and not Result
is that double-disconnect is a logic error, and there's no good way to handle it at runtime. People who still want to not care can check is_connected()
first, just like they could precede Gd::free()
with is_valid_instance()
)
TypedSignal
+ ConnectHandle
being high-level "type-safe" APIs, I don't see why users shouldn't be informed about mistakes? I would suggest we start with a panic and see how it's used -- it's always possible to relax, but it's much harder to introduce a panic into an API that didn't panic before.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are the connections usually valid beforehand?
In my case they are always valid upon disconnection (similarly to is_instance_valid
– it is better to address the root issue).
// Note: Like Godot's [`disconnect()`][https://docs.godotengine.org/en/stable/classes/class_object.html#class-object-method-disconnect] | ||
// and [`Signal::disconnect`][crate::builtin::Signal::disconnect] this raises an error, but *does not panic*. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regular //
comments are not Markdown, so this link formatting is obstructing readability.
But with the previous change, this can probably be removed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Absolutely - this used to be part of the docs before I turned it into a regular comment.
But I agree that just removing it is the correct choice now.
/// Returns `true` if the handle represents a valid connection | ||
/// | ||
/// Connections managed by a handle should normally always be valid, and in these cases this will always return `true`. | ||
/// | ||
/// However, if the signals and callables managed by this handle have been disconnected in any other way than by using | ||
/// [`disconnect()`][Self::disconnect] -- e.g., using [`signal.disconnect()`][crate::builtin::Signal::disconnect] or | ||
/// [`object.disconnect()`][crate::classes::Object::disconnect] -- this will return `false` instead. | ||
pub fn is_connected(&self) -> bool { |
There was a problem hiding this comment.
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 sentence
Connections managed by a handle should normally always be valid, and in these cases this will always return
true
.
conveys any useful information. People specifically call this method if they're interested when the connection is not valid, and "normally" doesn't help either. Let's keep docs as concise and information-dense as possible 🙂
/// Returns `true` if the handle represents a valid connection | |
/// | |
/// Connections managed by a handle should normally always be valid, and in these cases this will always return `true`. | |
/// | |
/// However, if the signals and callables managed by this handle have been disconnected in any other way than by using | |
/// [`disconnect()`][Self::disconnect] -- e.g., using [`signal.disconnect()`][crate::builtin::Signal::disconnect] or | |
/// [`object.disconnect()`][crate::classes::Object::disconnect] -- this will return `false` instead. | |
pub fn is_connected(&self) -> bool { | |
/// Whether the handle represents a valid connection. | |
/// | |
/// Returns false if the object is no longer alive. | |
/// | |
/// Returns _also_ false if the signals and callables managed by this handle have been disconnected in any other way than by using | |
/// [`disconnect()`][Self::disconnect] -- e.g. through [`Signal::disconnect()`][crate::builtin::Signal::disconnect] or | |
/// [`Object::disconnect()`][crate::classes::Object::disconnect]. | |
pub fn is_connected(&self) -> bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should maybe be tested, what do you think? 🤔
is_connected()
after callingfree()
on the objectis_connected()
after callingObject.disconnect()
on the object
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, this is much more concise.
is_connected() after calling free() on the object
Good idea, will add that.
What do we expect when calling is_connected()
on a freed object? A panic or false? I lean towards false, but open to both.
is_connected() after calling Object.disconnect() on the object
Do you mean testing is_connected()
panics if we do obj.disconnect(signal, callable)
?
We already test signal.disconnect(callable)
before is_connected()
, but I don't mind adding one for the object variant 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do we expect when calling
is_connected()
on a freed object? A panic or false? I lean towards false, but open to both.
Now that I think about it, this may actually not be that easy -- we don't actively store an object, since we use custom callables. It might be possible to associate one in the case of connect_self
/connect_other
calls.
But for now, maybe skip the is_connected()
-after-object-freed test and write a // TODO:
comment in the test. We can then look into it after this PR 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do 👍
Just to make sure we agree: the broadcasting object is stored in the ConnectHandle, so we might be able to do something for the case of:
let obj = SignalObject::new_alloc();
let handle = obj.signals().my_signal().connect(...);
obj.free();
handle.disconnect(); // <-- This will try to disconnect from a freed object
It could be possible to check if the object has been freed / is valid with is_instance_valid()
(and return false if it is not), but the wording of the documentation seems to discourage it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Intuitively, I would say:
-
Disconnecting a handle that's pointing to a freed object is a mistake and should panic.
- Just like an already disconnected signal prevents further disconnection.
-
Checking connection (
is_connected
) on a handle pointing to a dead object should returnfalse
.- API-wise, there is no other way to verify in
ConnectHandle
whether it's safe to disconnect. - I think it's a feature of Godot signals to disconnect when objects die? Not 100% sure though 🤔
- API-wise, there is no other way to verify in
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, agree with your points 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's a feature of Godot signals to disconnect when objects die? Not 100% sure though 🤔
If the signal object dies, yes. If the object behind the callable dies, not.
mod connection_handles { | ||
use crate::builtin_tests::containers::signal_test::connection_handles; | ||
use crate::framework::itest; | ||
use godot::{prelude::*, register::ConnectHandle}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please flatten imports, and avoid prelude
if possible.
You can however write use super::*
to avoid duplicating all the parent symbols.
#[var] | ||
#[init(val = 0i32)] | ||
counter: i32, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: is #[var]
necessary for the test here?
We can just access it as signal_object.bind().counter
, no?
let signal_object = SignalObject::new_alloc(); | ||
let moved_clone = signal_object.clone(); | ||
let connection_handle = signal_object.signals().my_signal().connect(move || { | ||
let mut my_signal_object = moved_clone.clone(); | ||
my_signal_object.bind_mut().increment_self(); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think cloning twice is necessary?
Also maybe use shorter identifiers -- this gives more weight to the other symbols like invoked methods:
let signal_object = SignalObject::new_alloc(); | |
let moved_clone = signal_object.clone(); | |
let connection_handle = signal_object.signals().my_signal().connect(move || { | |
let mut my_signal_object = moved_clone.clone(); | |
my_signal_object.bind_mut().increment_self(); | |
}); | |
let obj = SignalObject::new_alloc(); | |
let mut obj_moved = obj.clone(); | |
let handle = obj.signals().my_signal().connect(move || { | |
obj_moved.bind_mut().increment_self(); | |
}); |
eprintln!( | ||
"Connected: {:?}", | ||
godot_signal.is_connected(&godot_callable) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug statement still necessary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, this was not supposed to be here...
godot_signal.disconnect(&godot_callable); | ||
|
||
// If we check the connection, we should be able to see that connection is invalid and avoid a panic. | ||
let is_valid = connect_handle.is_connected(); | ||
assert!(!is_valid); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment is not necessary. It's obvious that is_connected
should be false after disconnect, and mention of the panic is not really relevant here.
godot_signal.disconnect(&godot_callable); | |
// If we check the connection, we should be able to see that connection is invalid and avoid a panic. | |
let is_valid = connect_handle.is_connected(); | |
assert!(!is_valid); | |
obj.disconnect(&godot_callable); | |
assert!(!obj.is_connected()); |
let signal_object = SignalObject::new_alloc(); | ||
|
||
let connect_handle = signal_object |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also here and in other places, you could simply name them obj
and handle
🙂
Thanks - will try to push a new version soon.
Sure - where should me put the new file? Under |
Yes, sounds good! Might need one or the other |
Changes some of the `connect_` methods for typed signals to return a handle containing information about the connection. Can be used to disconnect the signal when not needed. Makes it simpler to disconnect signals to objects that are dead / about to die: Object can call disconnect on handles before being freed (for example in a "impl Drop" implementation).
235c226
to
a6580fb
Compare
Pushed a new version. Largest change to the ConnectHandle is arguably that Also moved the tests to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot for the changes! Looks good, some tiny formatting parts that I can address later.
Btw, since the topic came up on Discord: I know the bar is sometimes a bit high for code style, and some comments are definitely nitpicks, so let me know if it gets too much. One option is that I modify the PR directly by pushing to it, but I don't want to do this without checking back first 🙂
Great to hear 🙂 |
Would possibly solve #1113
Connecting typed signals with
object.signals().signalname().connect_*()
andobject.signals().signalname().builder().connect_*()
now returns aConnectHandle
that can be used to disconnect the signal and receiver once it is no longer needed.It is not mandatory to save the returned
ConnectHandle
, but if it is kept around it can be used to disconnect any connections to that object before it dies.For example:
... should remove any connections from a source to
DisconnectOnDropNode
when it is dropped if the handles are stored inconnections_to_me
. This must be done manually however as there is no "detection" of signals targeting "dead" receivers.