-
-
Notifications
You must be signed in to change notification settings - Fork 342
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
The Listener interface is at the wrong level of abstraction #636
Comments
The main blocker here is that I think we also want to switch the My first idea was
|
The current trio docs use The curio docs use The asyncio docs aren't helpful; their equivalents say |
Wouldn't callers of _serve_one_listener want to put some kind of backpressure (probably a semaphore acquire, with a release once the accepted connection closes) in there to accept() BEFORE you start running into os process limits? ie "I want to accept a max of 200 concurrent connections. Anything beyond should be subject to the backlog and then dropped or at the discretion of the os"? This is mainly in the interest of general guidelines of backpressure -- never ask to do more until you know you have the resources to handle more. In a sync-world threadpool situation you'd be adding new connections to a bounded queue but here we're just calling nursery.start_soon.. at the very least a memory channel or some other callback should be used at the once-accepted stage, but ideally the wait should be just before the accept gets called in the first place, IMO. |
It may be worth noting a few of the endpoint types that Twisted provides built-in. Twisted provides guidelines to authors of libraries and applications on making endpoints configurable. One especially nice idea is defining a standard structure for endpoint into so it can be provided in a user config file, in envvars, on CLI, or over the network. Twisted's DSL is quite terse (e.g. |
Reading this post about QUIC implementation made me realize that this is another case where the I guess another option would be to immediately spawn the connection handler whenever a new handshake is started, and then do the handshake lazily the first time the connection handler tries to use the new stream. (This is how we handle TLS handshakes currently.) So I'm not sure this is a slam dunk argument by itself. But still, it's neat to see someone else comparing the exact same design approaches and coming to the same conclusion, in a totally different context.
Unfortunately, backpressure doesn't really work for listeners. The point of a listener is that you're accepting connections from a bunch of diverse sources, so in general, even if you reject one incoming connection then that doesn't stop everyone else from continuing to send you new incoming connections at the same rate as before. And for TCP in particular, the OS backlog limit is really unhelpful; if it overflows, then the client doesn't get any notification, so their TCP stack treats it like a lost packet and will automatically block until some timeout expires and then automatically retry. So now your clients are getting lousy service, while you have the same amount of load, and you can't even pick and choose which clients you're serving. Generally the best option when overloaded is to continue to accept connections as fast as possible, then immediately close the ones you don't want. (Or in more extreme cases, use some external DoS-protection system to gatekeep the traffic and protect your squishy Python program. Serious DoS protection requires specialized tools.) That said, with a
Yeah, Twisted's endpoint system is a very strong inspiration for Trio's stream/listener interfaces. The DSL part adds a lot of extra moving parts – not just a DSL parser, but also a whole plugin system, so that third-party libraries can extend the DSL – so I don't think it's a good fit for the Trio core. But definitely one of the goals here is that it should be possible to build a DSL like that on top of our stream/listener interfaces. |
We want to have an abstraction over the idea of "listening for incoming connection", because we want to be able to mix-and-match between protocol implementations and transport implementations – e.g. if you have an HTTP server or a Websocket server, you should be able to run it over TCP, over Tor, over TLS, over the haproxy PROXY protocol, over SSH tunneling, ... etc. Twisted gets a lot of mileage about of this idea with their "endpoints" system – one example that's particularly slick is txacme, which lets you transparently enable Let's Encrypt certs for any Twisted server. It works by defining a custom listener that detects when Let's Encrypt is connecting to validate a cert, and handles those connections automatically instead of passing them on to the application.
Our current abstraction is: a
Listener
is an object withaccept
andaclose
methods. In practice, users mostly don't call these methods directly; the main user of this interface isserve_listeners
, which callsaccept
in a loop, spawns tasks for incoming connections, andaclose
s the listener when cancelled.This basically works, but over time I've been building up a list of little issues that make me itch, and wonder if maybe we've drawn the abstraction boundary at the wrong place.
Graceful shutdown of Unix-domain socket servers
If you have a server that's listening on a Unix socket, it's possible to do a zero-downtime upgrade, without any complicated protocol for handing off the socket between the old process and the new one. You can have the new process atomically bind to the listening address, and after that point all new incoming connections go to the new process, while existing connections stay with the old process. Then you tell the old process to shut down once all current connections are handled. (Haproxy for example does zero-downtime upgrades this way.)
To do this correctly, though, your accept loop needs a way to "drain" all the connections that have already happened, without waiting for any more to arrive. But that's not possible with the current
Listener
interface: we would need something likeaccept_nowait
, which doesn't currently exist.Maybe this doesn't matter because it's better to do zero-downtime upgrades via file descriptor passing, which is much more general solution (in particular, it can work for TCP services, which this can't!). Or we could add
accept_nowait
, which would work but is kind of awkward since then wrappers likeSSLListener
would have to wrap bothaccept
andaccept_nowait
.Another case where
accept_nowait
might be useful is in "batched accept" (see #14). Again, I'm not totally sure whether this is something we actually need, but maybe. Or batched accept might be something we need temporarily to work around other issues, but then eventually get rid of.So... maybe
accept
alone isn't quite enough, but it isn't obvious what to do.See also: #14 (discussion of
accept_nowait
), #147 (about how to request a graceful shutdown), #279 (general discussion of unix domain servers and how they can support zero-downtime upgrades)Magic Let's Encrypt support
So let's say we want to implement something like
txacme
, and automatically handle incoming Let's Encrypt cert renewal. With the current system, this is possible, but has a few awkward features:We need a background task to manage the cert renewal scheduling. So you have to set that up somewhere, and then get a
Listener
object that assumes it's running.When a cert renewal is triggered, we need to know that someone is actually going to call
accept
soon. In practice this is true because everyone usesserve_listeners
, but it's not actually guaranteed by theListener
interface.For each incoming connection that might be from Let's Encrypt, we need to go through the TLS handshake before we actually know whether it is Let's Encrypt. We can't do that directly in
accept
, because that will block other incoming connections. So, who's going to do it?We could return the stream directly, and hope that the user will call
send_all
orreceive_some
soon, and then in there detect Let's Encrypt connections, handle the authentication, and then pretend to the user that this was some connection that just got dropped immediately. That would probably work OK in practice, but smells funny, because again it's making assumptions about how the user is callingaccept
.Whenever we accept new connection, we could run a background task (in the same nursery as the cert renewal background task, I guess), that does the handshake, and then either handles the connection directly or else puts it in a queue or something to be returned by
accept
. Pretty awkward, and also has to assume thatListener.accept
is being called in a loop (because each timeaccept
is called, we have to optimistically accept lots of connections, and return some of them later).Small warts of the current interface
Right now,
serve_listeners
has some special case code to handle EMFILE/ENFILE errors. These are a thing that can happen on Unix whenSocketListener
callsaccept
, and the proper way to handle them is to pause the accept loop for a bit. Theoretically,serve_listeners
isn't supposed to know anything aboutSocketListener
or Unix, but here it kind of has to, at least right now.Another small wart is that most of the time, the thing you pass around to represent what a server might listen to is not actually a
Listener
, but rather a list ofListener
s. For example,open_tcp_listeners
returns a list, andserve_listeners
takes a list. Not a huge deal, but a bit surprising. Why is it like this? Well,open_tcp_listeners
might have multiple sockets, and you can't easily implement a singleListener
that handles multiple sockets. A hypotheticalMultiListener.accept
would have to open a nursery and callaccept
on all of its sub-Listener
s... and then what happens if several of them succeed at once?A better abstraction?
So none of these is a smoking gun, but together they make me wonder if maybe we're approaching this wrong. In practice, everyone calls
accept
in a loop, and a lot of these issues seem to come from the fact thatListener
implementations have to pretend that they don't know that.Can we tweak the
Listener
interface so that it does know that?One possibility I considered is: replace
Listener.accept
withListener.accept_loop(send_channel)
that simply accepts connections over and over, and callssend_channel.put
on them. But... this would make wrapper classes likeSSLListener
pretty awkward, because they'd have to pull things off the channel, wrap them, and put them onto a new channel. You could make aWrapperListenerMixin
that encapsulates the logic for doing this, but then we're back to inheritance rather than composition, which is a big warning sign for me – IMO one of the main advantages of Trio streams over Twisted protocols is that Trio streams work well with composition, while the inversion of control in Twisted protocols kinda forces the use of inheritance, and theaccept_many
approach is basically replicating Twisted's inverted-control approach.Maybe it should just be...
Listener.serve(nursery, handler)
? This would basically mean movingserve_listeners
intoSocketListener
(orSocketsListener
?). So if we have a lot of primitiveListener
types, there'd be potential for code duplication. But... not sure there really is much to listen to other than sockets. Wrapper classes would work by wrappinghandler
and then callingsublistener.serve
.The text was updated successfully, but these errors were encountered: