Open thomaseizinger opened 2 years ago
I did a bit more exploration and we will need to change a few more things to make this possible.
Concretely, trying to apply ReadyUpgrade
(https://github.com/libp2p/rust-libp2p/pull/2855) in as many places as possible will help us with this change. Protocols like identify currently use the upgrade mechanism to directly read a message from the stream and close it afterwards. This is convenient because it means the handler is directly given the event / response without having to manually poll an OptionFuture
or FuturesUnordered
.
That being said, many protocols do much more complicated things within those upgrade traits (looking at you kademlia and relay) and I think it would overall be more consistent if we just move all of that into the ConnectionHandler
.
Potentially, this could be mitigated again by something like https://github.com/libp2p/rust-libp2p/pull/2852 but that abstraction is yet to proof itself.
That being said, many protocols do much more complicated things within those upgrade traits (looking at you kademlia and relay) and I think it would overall be more consistent if we just move all of that into the
ConnectionHandler
.
Relay seems to be by far the most complicated one here so any idea that we come up with should be stress-tested against that one.
Some more thoughts/analysis on this:
At the moment, the InboundUpgrade
and OutboundUpgrade
abstraction provide a form of type-safety. They tie the protocol identifier together with the logic on what should happen with every new inbound and outbound stream. Hardcoding ConnectionHandler
to receive "bare" substreams would remove this type-safety.
Realistically, I think type-safety would not suffer much. I'd claim it is equally easy to mix up what to put in InboundUpgrade
& OutboundUpgrade
vs inject_fully_negotiated_inbound
and inject_fully_negotiated_outbound
. On the ConnectionHandler
level, we again have type-safety because the handler couples the protocol identifier with the logic of how substreams are handled.
rust-libp2p
currently offers a timeout for substream upgrades. This timeout would be gone.
Generally, I think every IO operation should have a timeout and I think one design aspect of InboundUpgrade
and OutboundUpgrade
was to make it easy for users to have a timeout applied to their stream operations. Unfortunately, the abstraction doesn't seem to be quite right here as we can see from the identify example.
Even within our own protocols, we do not make consistent use of this timeout because it is sometimes not possible. For example, the identify protocol needs to observe the remote's address. This information is only available on the behaviour level. For incoming substreams, we thus pass the actual substream all the way to the behaviour and end up polling the future there.
Some ideas on how we could provide this functionality to users are:
FuturesUnordered
that wraps each item in a timeout: This would be a fairly generic building block that may be applicable in various situations. ConnectionHandler
s in particular could use this to enforce a timeout on "protocol futures"[^1].AsyncRead
& AsyncWrite
wrapper around NegotiatedSubstream
that races each read/write call with a timeout.Across rust-libp2p, we take great care (but don't always succeed) in making sure that back-pressure is properly enforced throughout the system to avoid unbounded memory and CPU growth. For inbound substreams, we limit the maximum number of negotiating substreams in HandlerWrapper
:
If we were to remove {In,Out}boundProtocol
, we would remove this negotiation step and together with it, the ability to limit the number of concurrently negotiating, inbound substreams.
In reality, this limit is already too easily by-passed without the user noticing by not performing all/any IO in the {In,Out}boundUpgrade
traits. Handing out the stream to the ConnectionHandler
"leaks" the stream and makes the upgrade process a no-op which immediately creates a slot for a new inbound stream in HandlerWrapper
.
[^1]: Protocol futures being futures that perform async reads and writes on a substream and finish with an event / message eventually.
Realistically, I think type-safety would not suffer much. I'd claim it is equally easy to mix up what to put in
InboundUpgrade
&OutboundUpgrade
vsinject_fully_negotiated_inbound
andinject_fully_negotiated_outbound
.
Agreed.
- For example, the identify protocol needs to observe the remote's address. This information is only available on the behaviour level.
If I am not mistaken it is passed to the behaviour as the handler does not know the local information, not the remote.
- For incoming substreams, we thus pass the actual substream all the way to the behaviour and end up polling the future there.
As an aside, I think we should change that. I don't think we should do I/O on the main event loop.
Some ideas on how we could provide this functionality to users are:
Agreed, that we should provide this timeouts through an alternative mechanism.
In reality, this limit is already too easily by-passed without the user noticing by not performing all/any IO in the
{In,Out}boundUpgrade
traits. Handing out the stream to theConnectionHandler
"leaks" the stream and makes the upgrade process a no-op which immediately creates a slot for a new inbound stream inHandlerWrapper
.
Agreed. I don't think the current mechanism is solid, and I do agree that it is mostly being bypassed anyways.
[^1]
[^1]: Neat. Didn't know GitHub supports footnotes.
- For incoming substreams, we thus pass the actual substream all the way to the behaviour and end up polling the future there.
As an aside, I think we should change that. I don't think we should do I/O on the main event loop.
Tracked here: https://github.com/libp2p/rust-libp2p/issues/2885
To progress on this front (other than something like #2878), I am thinking of going with a FuturesUnordered
wrapper that:
None
(i.e. hides this in the type interface)The idea would be that this component is general enough so implementations of ConnectionHandler
s can use it for any kind of async processing with substream upgrades only being one of them.
Wraps the future in a timeout
Never returns
None
(i.e. hides this in the type interface)
If I may add a wish, first class bounding of the size of the FuturesUnordered
would be appreciated. E.g. for libp2p-kad
we need to bound the size to 16.
So I thought a bit more about this. The current abstraction allows us to directly yield a message from the upgrade. That actually takes some boilerplate off the handler and makes the handler simpler to implement.
Except that this isn't quite true :)
You can't implement a ConnectionHandler
without implementing InboundUpgrade
(and OutboundUpgrade
), meaning the net-gain of a simpler handler is offset by having the user learn another abstraction.
Additionally, not all protocols follow the same pattern of reading only a single message from a stream, meaning you often have to also yield the stream together with the message from the upgrade. At that point, you start mixing paradigms in how messages are sent and received on the streams and I'll claim that it would be simpler to always do it one way.
In the long-run, we could consider removing the UpgradeInfo
type and have users directly give us a list of protocols that we should listen on. That would remove another layer although that one arguably does provide some type safety so it is not as clear-cut from my perspective.
To progress on this front (other than something like #2878), I am thinking of going with a
FuturesUnordered
wrapper that:
- Wraps the future in a timeout
- Never returns
None
(i.e. hides this in the type interface)The idea would be that this component is general enough so implementations of
ConnectionHandler
s can use it for any kind of async processing with substream upgrades only being one of them.
Implementation suggestion:
TimedFutures
new
, add
and poll
futures-timer
Once we have this type, we can:
{In,Out}boundUpgrade
within protocols/
to use ReadyUpgrade
{In,Out}boundUpgrade
implementation as a future into TimedFutures
TimedFutures
as part of ConnectionHandler::poll
After that, we can change the definition of ConnectionHandler
to no longer require the {In,Out}boundUpgrade
traits and always directly pass a NegotiatedSubstream
.
The TimedFutures
type will allow us to replace the "timeout" feature currently provided by the upgrade mechanism. @mxinden seems to agree that we won't be losing much type-safety.
Hence, the only feature that is currently provided and will be removed is the "backpressure" on inbound streams. It isn't really backpressure because it only works if the stream is entirely consumed within the {In,Out}boundUpgrade
traits. Once we hand it out to the ConnectionHandler
, the limit no longer applies.
@libp2p/rust-libp2p-maintainers Do you agree that we can remove this feature for now? It is already tracked in https://github.com/libp2p/rust-libp2p/issues/3078. I am of the opinion that pushing for this issue (no upgrades in connection handlers) gives us a cleaner plate to work on and should make it easier to implement backpressure in a consistent way.
Hence, the only feature that is currently provided and will be removed is the "backpressure" on inbound streams. It isn't really backpressure because it only works if the stream is entirely consumed within the
{In,Out}boundUpgrade
traits. Once we hand it out to theConnectionHandler
, the limit no longer applies.
Agreed. I don't think there is much gain from this.
@libp2p/rust-libp2p-maintainers Do you agree that we can remove this feature for now?
Agreed. I am in favor of proceeding here.
The
TimedFutures
type will allow us to replace the "timeout" feature currently provided by the upgrade mechanism. @mxinden seems to agree that we won't be losing much type-safety.
I don't have an opinion on how to roll this out. The above suggestion (TimedFuture
) makes sense to me.
Simplify the
ConnectionHandler
interface. This is one of the first interfaces users get in touch with when the implement their own protocols. Making it as simple as possible without removing functionality should be the goal. All but{To,From}Behaviour
should be removed and we should directly hand the negotiated stream to theConnectionHandler
without any form of "upgrade".