-
Notifications
You must be signed in to change notification settings - Fork 704
Add structured concurrency aware ServerBootstrap bind #3378
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: main
Are you sure you want to change the base?
Add structured concurrency aware ServerBootstrap bind #3378
Conversation
Sources/NIOPosix/Bootstrap.swift
Outdated
_ onConnection: @escaping @Sendable ( | ||
_ inbound: NIOAsyncChannelInboundStream<Inbound>, | ||
_ outbound: NIOAsyncChannelOutboundWriter<Outbound> |
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.
We should give the user the entire NIOAsyncChannel
instead of just the two halfs. They might wanna access the underlying channel which they can through the asyncChannel.channel
property
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.
fixed.
Sources/NIOPosix/Bootstrap.swift
Outdated
_ onConnection: @escaping @Sendable ( | ||
_ inbound: NIOAsyncChannelInboundStream<Inbound>, | ||
_ outbound: NIOAsyncChannelOutboundWriter<Outbound> | ||
) async throws -> () |
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.
Not sure if this method should be throwing and if we should use a throwing task group here. Right now those errors will lead to the task group getting cancelled which doesn't seam right to me? At least we should document what happens.
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.
fixed.
Sources/NIOPosix/Bootstrap.swift
Outdated
/// specifies the exact binding location, including IPv4, IPv6, or Unix domain addresses. | ||
/// | ||
/// - Parameter address: The socket address to bind to | ||
public static func address(_ address: SocketAddress) -> BindTarget { |
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.
NIT: Since it might be confused with a DNS address
public static func address(_ address: SocketAddress) -> BindTarget { | |
public static func socketAddress(_ socketAddress: SocketAddress) -> BindTarget { |
/// - Note: The bind method respects task cancellation which will force close the server. If you want to gracefully | ||
/// shut-down use the quiescing helper approach as outlined above. | ||
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) | ||
public func bind<Inbound: Sendable, Outbound: Sendable>( |
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 I implement a server using this method, how can I access the address that the listening socket was bound to? (e.g. I set port to zero and want to know the actual port I got.)
Currently it seems the only way to do this is via the parent
channel of an accepted connection. This seems like a pretty big API deficiency.
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 that's a good point and we probably need another closure that is called once the server channel is bound.
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 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 should be async
(see other comment) and I'm not keen on the name. Maybe something along the lines of onTCPListener
/ onListeningChannel
? That'd at least be consistent with onConnection
.
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 agree. I think actually it should be async
and throws
since it is similar to a with-style
method i.e. it runs in the calling task. I also like with the onXXX
naming. To throw in one more alternative onListeningChannelBound
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.
went for onListeningChannel
and handleConnection
(as @FranzBusch suggested somewhere else). Please bikeshed! @glbrntt @FranzBusch @Lukasa
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 would bias towards some level of consistency.
We already have the established {server,child}ChannelInitializer
pattern and we use childChannelInitializer
in this signature already.
So perhaps we should have:
handleServerChannel
andhandleChildChannel
This has clear parallels with the existing bootstrap APIs.
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.
@glbrntt love it!
try await connectionChannel.executeThenClose { _, _ in | ||
await onConnection(connectionChannel) | ||
} |
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 understand the rationale for passing in the NIOAsyncChannel
but if we use this approach the API becomes pretty confusing:
- If I use this structured API I mustn't call
executeThenClose
and I'm expected to useinbound
andoutbound
which are no longer deprecated - If I use another
bind
method which isn't structured then I must callexecuteThenClose
and shouldn't useinbound
andoutbound
I think the usability here needs a little more thought. We could just pass in the inbound, outbound and channel.
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.
My thought process here was that initially when we designed the NIOAsyncChannel
we wanted folks to use inbound
and outbound
. Then we realized our bootstraps aren't structured and we added the executeThenClose
method. Now with the structured bootstraps we can go back to inbound/outbound
. However, we need to tell one coherent story so I would argue to deprecate all the other bind
methods + executeThenClose
and push users to these new structured APIs.
We do have to consider the ecosystem churn this produces but I personally would push on us deprecating the non-structured bootstrap methods.
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.
@glbrntt I think I'm with Franz here. Does this approach work for you as well?
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'm okay with that if we can first prove out that the new APIs (not just the server one) are suitable replacements. I think we burned some good will with the first deprecate and replace and so we should be really sure that these APIs are going to stick.
To that end it might be worth adding the new APIs as underscored and holding off on removing the deprecation warning on the inbound
and outbound
.
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'm not sure that I agree with that. Our view on approaching structured concurrency has significantly evolved in the last two years. The initial set of APIs was introduced in June 2023. So we change those APIs after two years. Who will adopt those APIs if they are underscored so that we can get feedback from the new APIs?
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.
The point is we introduced the APIs and then changed them.
Before we move to the third iteration of this API we should be really damn sure that the model we land on works and is coherent for both server, client and multiplexing.
It being underscored gives us a chance to try it out (e.g. in gRPC, Vapor) without fully committing to it.
} | ||
} | ||
} | ||
return .success(()) |
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.
Does this implementation need to know about quiescing and/or graceful shutdown?
What's the expected behavior when a server stops accepting clients I.E. ServerBootstrap closes? Do all child tasks cancel, or do we wait for them to shut down? This kinda plays into graceful shutdown from Service Lifecycle. Can a connection continue to live while the parent channel is closed?
e637823
to
c05579c
Compare
Sources/NIOPosix/Bootstrap.swift
Outdated
childChannelInitializer: childChannelInitializer | ||
) | ||
|
||
onceStartup(channel.channel) |
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 this should be part of the task group instead of before it.
Users can modify the server channel pipeline in the bootstrap so they should be able to interact with it while the server is running (maybe they're waiting for a signal before firing something down the pipeline).
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'm not sure I'm following. When you say part of the task group you mean moving it into the beginning of the withDiscardingTaskGroup
or into a child task?
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.
Sorry, that was unclear, I meant as a child task.
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'm not sure I agree with this since it significantly changes the composability of the closure. When it moves into a child task then it must become sending
at least which means you can't interact with any task isolated state. Let's take a common server example
final class HTTPServer {
var state = "starting"
func run() async throws {
try await ServerBootstrap.bind(
target: .hostAndPort(self.host, self.port),
onListeningChannelBound: { channel in self.state = "bound: \(channel.localAddress!)" }
onConnection: { ... }
)
}
}
If it moves into a child task then onListeningChannelBound: { channel in self.state = "bound: \(channel.localAddress!)" }
will need the state to move into a Mutex
or use another synchronization mechanism.
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 means that you can only use the channel before the server starts processing requests though.
What if I want to later send an event into the server channel?
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.
Isn't the intent of these APIs to use structured concurrency?
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.
@FranzBusch but you 100% need a lock around that.
The code I pasted above is data race safe. Channel
is Sendable
so you are allowed to store it in your local property. Also the closure is not sending
or @Sendable
.
Isn't the intent of these APIs to use structured concurrency?
I hear you and I can buy into saying that this is the reason that it needs to be called within a child task but that child tasks is going to introduce new race conditions such as a connection being handled before the onListeningChannelBound
closure is called. So maybe we need both?
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.
Hmm, maybe? I don't love giving two separate closures here, I think it makes the API more confusing than it should be.
When would the race matter? Anything that needs to be applied before channels are accepted should be done in the channel initializer.
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.
@Lukasa do you have an opinion here?
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 wonder if the tension is best addressed in a different way.
The serverChannelInitializer
can be used for unstructured access to the server channel, and it can be used to escape the server channel reference as needed, though it does require a lock to do it. There is no way to get access to the server channel before you have bound it other than that spelling, and that spelling isn't structured. We can choose to make it so, but I'm honestly not sure it's the right answer.
However, having an async closure to operate on the server channel does make sense. This task provides a place to do asynchronous operations that may want to monitor the server channel in various ways. So my instinct is that I am leaning towards George's proposal, with the suggestion that we should document the behaviour required for non-structured access to the server channel.
In the event that we want a "before active" step, that's still something we can add later, and should probably have that specific name to indicate its lifecycle behaviour.
/// - Note: The bind method respects task cancellation which will force close the server. If you want to gracefully | ||
/// shut-down use the quiescing helper approach as outlined above. | ||
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) | ||
public func bind<Inbound: Sendable, Outbound: Sendable>( |
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 should be async
(see other comment) and I'm not keen on the name. Maybe something along the lines of onTCPListener
/ onListeningChannel
? That'd at least be consistent with onConnection
.
} | ||
|
||
/// The writer for writing outbound messages. | ||
@available(*, deprecated, message: "Use the executeThenClose scoped method instead.") |
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.
Why are we undeprecating these?
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.
because we want to pass in the NIOAsyncChannel and users shall then use inbound
and outbound
on it. So that potentially we can add further properties to NIOAsyncChannel
later.
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.
Can we undeprecate with an spi?
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.
No you can't. What you can do is put it behind a trait like _ExperimentalStructuredBootstrap
. You just have to duplicate the properties.
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.
should I intro new in
and out
properties and mark them with the same @_spi
?
/// .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) | ||
/// .bind( | ||
/// target: .hostAndPort(self.host, self.port), | ||
/// childChannelInitializer: { channel 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.
Can you use multiple trailing closure syntax here?
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, this is done in the NIOTCPEchoExample which is part of this pr.
/// } | ||
/// .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) | ||
/// .bind( | ||
/// target: .hostAndPort(self.host, self.port), |
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.
Should the label here be to:
? e.g. bind(to: .hostAndPort("foo", 0))
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've discussed this with @Lukasa, and he wants this to be target, as this type might change in the future. With a special label we can deprecate much better, than with a to:
label.
Sources/NIOPosix/Bootstrap.swift
Outdated
/// - handleConnection: A closure to handle the connection. Use the channel's `inbound` property to read from | ||
/// the connection and channel's `outbound` to write to the connection. | ||
/// - onListeningChannel: A closure that will be called once the server has been started. Use this to get access to | ||
/// the serverChannel, if you used port `0` in the ``BindTarget``. You can also use it 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.
This makes it sound like you only get access if you set the port to 0
.
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.
Tried to reworded it.
Sources/NIOPosix/Bootstrap.swift
Outdated
_ channel: NIOAsyncChannel<Inbound, Outbound> | ||
) async -> (), | ||
onListeningChannel: @Sendable @escaping ( | ||
NIOAsyncChannel<NIOAsyncChannel<Inbound, Outbound>, Never> |
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 are a lot of documented constraints on this parameter. It feels like a very odd design decision to pass the user a NIOAsyncChannel
on which they can't use inbound
/outbound
/executeThenClose
. Why don't we just provide them with a Channel
(or some other type)?
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 agree that in this case it is best to pass the plain Channel
.
/// - Note: The bind method respects task cancellation which will force close the server. If you want to gracefully | ||
/// shut-down use the quiescing helper approach as outlined above. | ||
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) | ||
public func bind<Inbound: Sendable, Outbound: Sendable>( |
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 would bias towards some level of consistency.
We already have the established {server,child}ChannelInitializer
pattern and we use childChannelInitializer
in this signature already.
So perhaps we should have:
handleServerChannel
andhandleChildChannel
This has clear parallels with the existing bootstrap APIs.
This is a very early draft of a structured concurrency aware
ServerBootstrap
bind method.To be discussed: