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

Provide abstraction for a channel #160

Merged
merged 31 commits into from
Mar 25, 2023

Conversation

garryod
Copy link
Collaborator

@garryod garryod commented Mar 19, 2023

Again, with a view towards making multi-channel sockets easier to use, I've abstracted channels by implementing a WebRtcChannel struct which provides send & receive. This should make the code from @johanhelsing's last devblog a lot cleaner.

Unfortunately I've had to drop broadcast as it would require channels to know about their peers, it wasn't in use anywhere as far as I can tell, though maybe we should still figure out a way of re-adding it

Copy link
Collaborator

@simbleau simbleau left a comment

Choose a reason for hiding this comment

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

If you were able to spend another 30 minutes trying to make this as idiomatic as the previous API, and add more documentation, what would the result look like? I'd like to see.

Overall I found this a little jarring of a change, because there's a ton of unwraps/borrows/etc. that aren't obvious to me on first glance, even on our simple examples. There's definitely some opportunity for documentation on some things, too.

examples/simple/src/main.rs Outdated Show resolved Hide resolved
examples/simple/src/main.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
@garryod
Copy link
Collaborator Author

garryod commented Mar 19, 2023

Oops, this was supposed to be a draft. Just wanted to get something on the table before I headed out for training. Definitely needs some tidying up around the edges. Think I might return some error enum that has a couple variants like NoSuchChannel and a ChannelAlreadyTaken

@garryod garryod marked this pull request as draft March 19, 2023 19:55
@garryod garryod force-pushed the webrtc-channel branch 2 times, most recently from 2d583d1 to 81b9b02 Compare March 19, 2023 21:44
@garryod garryod requested a review from simbleau March 19, 2023 21:56
@garryod garryod marked this pull request as ready for review March 19, 2023 22:26
Copy link
Collaborator

@simbleau simbleau left a comment

Choose a reason for hiding this comment

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

Small nitpicks

matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
@simbleau
Copy link
Collaborator

Can you also explain to me what take_channel is used for? Why would someone take the channel out of the socket, and what are the effects if you do? I was surprised to see the channels is a Vec<Option> instead of just Vec.

Is the method necessary, could we do without Option there?

@johanhelsing
Copy link
Owner

johanhelsing commented Mar 20, 2023

Can you also explain to me what take_channel is used for? Why would someone take the channel out of the socket, and what are the effects if you do? I was surprised to see the channels is a Vec instead of just Vec.

Is the method necessary, could we do without Option there?

It's because ggrs needs to take ownership of the socket, and if you still want to be able to send/receive on the other channels outside ggrs, you need some way of turning a socket into separate parts. In this PR, it's done by removing the channels. I think they're options just so the errors can be nice (so you know there was a channel there with that id, but it's been taken).

Other options for this solution would be:

  1. Use crossbeam_channel receivers which support clone and try to make the entire socket struct cloneable (not completely sure if this is possible).
  2. Add a split method that would return (SocketWithoutChannels, Vec<WebRtcChannel>). SocketWithoutChannels would not have any send/receive methods, just id, update_peers, connected_peers etc.

Unfortunately I've had to drop broadcast as it would require channels to know about their peers, it wasn't in use anywhere as far as I can tell, though maybe we should still figure out a way of re-adding it

Not having broadcast on the channels is probably fine for now. I'm sure we can find some other way of implementing it. Could for instance make the unbounded channel in WebRtcChannel::send take an enum:

enum Receiver {
    Single(PeerId),
    Broadcast, // or All?
}

(and this would just be internal API).

Another note. I'm wondering if it would make sense to add Bevy-like labels for channels. i.e.:

#[derive(ChannelLabel)]
struct MyReliableChannel;

let (sock, fut) = WebRtcSocket::builder(url).add_reliable_channel(MyReliableChannel).build();

socket.channel(MyReliableChannel).send(packet, peer);

@simbleau
Copy link
Collaborator

simbleau commented Mar 20, 2023

I can't really confirm or deny whether I'd like the (SocketWithoutChannels, Vec) approach until I see how drastic the API change is.

@johanhelsing +1 For labels, they could help us with this problem as well.

For example, store channels as a HashMap<Label, WebRtcChannel> and then the API could be something like socket.send(Label, Packet) -> Result<(), Error>?

You could remove the channel that way, and .send() can return several errors, like TrySendError, SocketNotFoundError, SocketTakenError, etc.

To me, that is better than socket.channel(0).unwrap().send(...).unwrap() since two unwraps would become one: socket.send(0, ...).unwrap()

@simbleau
Copy link
Collaborator

simbleau commented Mar 20, 2023

One more suggestion, if the prior isn't favorable:

Model the sending as a builder, e.g. socket.channel(label).data(packet).send().await -> Result<(), Error>

@garryod
Copy link
Collaborator Author

garryod commented Mar 20, 2023

@johanhelsing +1 For labels, they could help us with this problem as well.

For example, store channels as a HashMap<Label, WebRtcChannel> and then the API could be something like socket.send(Label, Packet) -> Result<(), Error>?

You could remove the channel that way, and .send() can return several errors, like TrySendError, SocketNotFoundError, SocketTakenError, etc.

To me, that is better than socket.channel(0).unwrap().send(...).unwrap() since two unwraps would become one: socket.send(0, ...).unwrap()

Not sure I see how labels would solve this, we need to facilitate external code taking ownership of channels (i.e. the tx & rx) to make integrations like ggrs behave nicely with each other

@garryod
Copy link
Collaborator Author

garryod commented Mar 20, 2023

Other options for this solution would be:

  1. Use crossbeam_channel receivers which support clone and try to make the entire socket struct cloneable (not completely sure if this is possible).
  2. Add a split method that would return (SocketWithoutChannels, Vec<WebRtcChannel>). SocketWithoutChannels would not have any send/receive methods, just id, update_peers, connected_peers etc.
  1. This is possible, but the behaviour is non-trivial when you have multiple receivers and is probably more of a foot gun than anything else
  2. This works, but doesn't really change the problem, just moves the bookkeeping from library code to user code which is probably not what we want

@garryod
Copy link
Collaborator Author

garryod commented Mar 20, 2023

One more suggestion, if the prior isn't favorable:

Model the sending as a builder, e.g. socket.channel(label).data(packet).send().await -> Result<(), Error>

This is an interesting idea, will give it a go. Not sure how idiomatic it will feel though

@garryod
Copy link
Collaborator Author

garryod commented Mar 20, 2023

One option we have is to add back the convenience functions to sending and receiving on the Socket e.g.

  pub fn recieve_on_channel(
      &mut self,
      channel: usize,
  ) -> Result<Vec<(PeerId, Packet)>, ChannelError> {
      Ok(self.channel(channel)?.receive())
  }

  pub fn send_on_channel(
      &mut self,
      packet: Packet,
      peer: PeerId,
      channel: usize,
  ) -> Result<(), ChannelError> {
      Ok(self.channel(channel)?.send(packet, peer))
  }

But I think we'd be better of sticking to having one way of doing this to avoid confusion

@simbleau
Copy link
Collaborator

@johanhelsing +1 For labels, they could help us with this problem as well.

For example, store channels as a HashMap<Label, WebRtcChannel> and then the API could be something like socket.send(Label, Packet) -> Result<(), Error>?

You could remove the channel that way, and .send() can return several errors, like TrySendError, SocketNotFoundError, SocketTakenError, etc.

To me, that is better than socket.channel(0).unwrap().send(...).unwrap() since two unwraps would become one: socket.send(0, ...).unwrap()

Not sure I see how labels would solve this, we need to facilitate external code taking ownership of channels (i.e. the tx & rx) to make integrations like ggrs behave nicely with each other

I believe Hashmap::remove() returns the item removed, giving you ownership back. You can take it out of the hashmap.

@garryod
Copy link
Collaborator Author

garryod commented Mar 20, 2023

HashMap::get and HashMap::remove both return Option<T>, meaning the interface would still require socket.channel(some_key).unwrap().send(data, peer). Though I do think named / tagged channels would be nice

@simbleau
Copy link
Collaborator

simbleau commented Mar 20, 2023

HashMap::get and HashMap::remove both return Option<T>, meaning the interface would still require socket.channel(some_key).unwrap().send(data, peer). Though I do think named / tagged channels would be nice

So it's not possible to combine them, e.g. socket.send(channel, data, peer).unwrap()?

@garryod
Copy link
Collaborator Author

garryod commented Mar 21, 2023

HashMap::get and HashMap::remove both return Option<T>, meaning the interface would still require socket.channel(some_key).unwrap().send(data, peer). Though I do think named / tagged channels would be nice

So it's not possible to combine them, e.g. socket.send(channel, data, peer).unwrap()?

It's possible to combine them regardless of whether we use a HashMap or a Vec, my reservation in doing this is that we end up with two distinct ways of achieving the same thing which complicates the mental model.

@johanhelsing
Copy link
Owner

johanhelsing commented Mar 23, 2023

Having to always do the double unwrap on send/receive does not make for great ergonomics. At least not for simple cases, we're you're just interested in one channel, and don't need to decompose or hand off anything.

As I see it, we have three options:

  1. As suggested by @simbleau , provide helper method that combines them socket.send(0, data, peer).unwrap()
  2. Mirror Bevy's API and produce both a panicking and a non-panicking versions of the channel accessor. I.e.: socket.channel(0).send(data, peer) would panic if the channel is not there, while if let Ok(channel) = socket.get_channel(0) would not.

My third idea, which is still kind of on the draft stage, not sure if I like it or not, but thought I'd air it:

Try to force it to be infallible by being generic over an iterable enum:

#[derive(EnumIter, Debug)]
enum MyChannel {
    Reliable,
    Ggrs,
    Custom,
}

impl ChannelConfig for MyChannel {
    fn config(self) -> ChannelConfig {
        match self {
            Reliable => ChannelConfig::reliable(),
            Ggrs => ChannelConfig::ggrs(),
            Custom => ChannelConfig {
                // ...
            }
        }
    }
}

// type annotation added for clarity
let socket: WebRtcSocket<MyChannel> = WebRtcSocket::builder::<MyChannel>(url).build();

// or simply
let socket = WebRtcSocket::new::<MyChannel>(url);

socket.send(MyChannel::Reliable, data, peer); // infallible, can not panic

let (socket_control, data_channels) = socket.split();
start_ggrs_session(data_channels.remove(MyChannel::Ggrs).unwrap());
let my_game_socket = MyGameSocket {
    socket_control,
    data_channel: data_channels.remove(MyChannel::Reliable).unwrap()),
}
assert!(data_channels.empty());

Note, no take_channel on WebRtcSocket => guaranteed to always have all channels.

This would make run-time channel config decisions more clunky to do, but it would also remove quite a few footguns.

@johanhelsing johanhelsing mentioned this pull request Mar 23, 2023
3 tasks
@simbleau
Copy link
Collaborator

simbleau commented Mar 23, 2023

I'll be honest, I don't think this makes the right tradeoffs. Particularly because I think it's high boilerplate/glue code.

#[derive(EnumIter, Debug)]
enum MyChannel {
    Reliable,
    Ggrs,
    Custom,
}

impl ChannelConfig for MyChannel {
    fn config(self) -> ChannelConfig {
        match self {
            Reliable => ChannelConfig::reliable(),
            Ggrs => ChannelConfig::ggrs(),
            Custom => ChannelConfig {
                // ...
            }
        }
    }
}

I have a proposal which I think strikes the right balances:

Keep the builder approach, with labels:
WebSocketBuilder::new().reliable_channel('label1').unreliable_channel('label2').ggs_channel('label3'),

Store the channels in a hashmap and mirror Bevy's API (per @johanhelsing)
panicky: socket.channel('label1').send(data, peer)
non-panicky: socket.get_channel('label1')

and if there's no reason ever that we would take a channel other than for ggrs, let's just make that a feature method like into_parts,
WebRtcSocket::into_ggrs_parts -> (WebRtcSocket, ggrs_channel)

@johanhelsing
Copy link
Owner

johanhelsing commented Mar 23, 2023

Guess we could also do a mix of the two:

#[derive(EnumIter, Debug)]
enum MyChannel {
    Chat,
    Ggrs,
    Custom,
    Unspecified // <-- will be configured with ChannelConfig::default()
}

let socket = WebRtcSocket::builder::<MyChannel>(url)
    .reliable_channel(MyChannel::Chat)
    .ggrs_channel(MyChannel::Ggrs)
    .custom_channel(MyChannel::Custom, ChannelConfig { /* ... */ })
    .build();

We trade the complexity of an added generic for knowing that WebRtcSocket::channel will never fail or panic.

We could always start with the bevy-like channel/get_channel/remove_channel, with indices, then we can revisit labels/enums in another PR?

@garryod
Copy link
Collaborator Author

garryod commented Mar 24, 2023

Try to force it to be infallible by being generic over an iterable enum

I'm not sure this can ever be sufficient as channel() may fail if the desired channel has already been taken. Though this should be possible when variadic generics are introduced (RFC).

I think I've got another solution which might be nice, but is to do with how we integrate with bevy. If we were to derive Resource for the WebRtcSocket itself then we could forgo the first unwrap, requiring only the unwrap on channel. Unfortunately due to orphan rules I can't show this in the example here to will rebase the bevy extension onto this branch and do it there

@johanhelsing
Copy link
Owner

johanhelsing commented Mar 24, 2023

Try to force it to be infallible by being generic over an iterable enum

I'm not sure this can ever be sufficient as channel() may fail if the desired channel has already been taken.

If we only have split(self) and not take_channel(0), we can.

@johanhelsing johanhelsing mentioned this pull request Mar 24, 2023
@garryod
Copy link
Collaborator Author

garryod commented Mar 24, 2023

Try to force it to be infallible by being generic over an iterable enum

I'm not sure this can ever be sufficient as channel() may fail if the desired channel has already been taken.

If we only have split(self) and not take_channel(0), we can.

Surely we would then need to decompose this vector in some guaranteed way? Perhaps I'm missing something obvious here but I'm not sure I see how it would work

@johanhelsing
Copy link
Owner

johanhelsing commented Mar 24, 2023

Try to force it to be infallible by being generic over an iterable enum

I'm not sure this can ever be sufficient as channel() may fail if the desired channel has already been taken.

If we only have split(self) and not take_channel(0), we can.

Surely we would then need to decompose this vector in some guaranteed way? Perhaps I'm missing something obvious here but I'm not sure I see how it would work

if we iterate over the enum on init, e.g.:

let channel_configs = MyChannel::iter()
    .map(|c| explicit_configs.get(c).unwrap_or_default())
    .collect();
// create socket

And we have no way to remove channels from hashmap, then

self.channels.get(channel).unwrap()

cannot panic.

I'm thinking WebRtcSocket::split would consume the socket and return a (WebRtcSocketControl, HashMap<MyChannel, DataChannel>).

Still I'm not sure I like my idea. Let's start with yours, it's always possible to revisit (and probably easier to explain if I just do it in a PR). And we need the channel abstraction in any case.

@johanhelsing johanhelsing added the enhancement New feature or request label Mar 24, 2023
Copy link
Owner

@johanhelsing johanhelsing left a comment

Choose a reason for hiding this comment

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

Some final nits/spelling stuff. If you're pressed on time, don't feel like you have to address them all. This looking really great :)

Love how much simpler the bevy_ggrs example got

matchbox_socket/src/webrtc_socket/error.rs Outdated Show resolved Hide resolved
examples/bevy_ggrs/src/main.rs Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
matchbox_socket/src/webrtc_socket/socket.rs Outdated Show resolved Hide resolved
Copy link
Owner

@johanhelsing johanhelsing left a comment

Choose a reason for hiding this comment

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

I'm really happy with this, and have no more comments. @simbleau do you want to have a look before I merge?

@simbleau
Copy link
Collaborator

simbleau commented Mar 25, 2023

I think most of my points are addressed regarding API complexity. :) Happy to see a good resolution.

Do note: Please squash commits lol

@garryod
Copy link
Collaborator Author

garryod commented Mar 25, 2023

Do note: Please squash commits lol

Can squash them down to something reasonable or you can "squash and merge". That being said, I am usually a strong advocate for only using rebase merges as it results in a much cleaner history

@johanhelsing johanhelsing merged commit be1e58b into johanhelsing:main Mar 25, 2023
@johanhelsing
Copy link
Owner

Oops I already merged 👼 . I like linear history if every commit is readable and bisectable, but sometimes hard to achieve on small/midsize open source projects.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants