Repository navigation
Support network path change notifications #147
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?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the Swift HTTP Server open source project | ||
| // | ||
| // Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors | ||
| // Licensed under Apache License v2.0 | ||
| // | ||
| // See LICENSE.txt for license information | ||
| // See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| import NIOCore | ||
|
|
||
| /// This channel handler keeps a connection's ``NIOHTTPServer/ConnectionContext`` current with the events fired into the | ||
| /// connection channel's pipeline, and reports them through the context's | ||
| /// ``NIOHTTPServer/ConnectionContext/connectionEvents``, which it finishes once the connection closes. | ||
| /// | ||
| /// Only HTTP/3 connection channels have one: a QUIC connection can migrate to a new network path, whereas a TCP | ||
| /// connection's path is fixed for its lifetime. | ||
| @available(anyAppleOS 27.0, *) | ||
| final class ConnectionEventsHandler: ChannelInboundHandler, RemovableChannelHandler { | ||
| typealias InboundIn = NIOAny | ||
| typealias InboundOut = NIOAny | ||
|
|
||
| private let connectionContext: NIOHTTPServer.ConnectionContext | ||
|
|
||
| init(connectionContext: NIOHTTPServer.ConnectionContext) { | ||
| self.connectionContext = connectionContext | ||
| } | ||
|
|
||
| func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { | ||
| // TODO: Translate the QUIC stack's own events (for example, the peer migrating the connection to a new path, or | ||
| // a new path being validated) into `ConnectionEvent`s, once it fires them into the connection channel. | ||
| if let event = event as? NIOHTTPServer.ConnectionEvent { | ||
| self.connectionContext.connectionEventReceived(event) | ||
| } | ||
|
|
||
| context.fireUserInboundEventTriggered(event) | ||
| } | ||
|
|
||
| func channelInactive(context: ChannelHandlerContext) { | ||
| self.connectionContext.unsubscribeFromConnectionEvents() | ||
| context.fireChannelInactive() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| import NIOConcurrencyHelpers | ||
| import NIOCore | ||
| import NIOSSL | ||
| public import X509 | ||
|
|
@@ -36,19 +37,25 @@ extension NIOHTTPServer { | |
| /// | ||
| /// Carries connection-scoped data such as the negotiated HTTP version, the | ||
| /// peer / local addresses, and the peer's validated certificate chain (when | ||
| /// applicable). | ||
| /// applicable), along with the events that happen on the connection. | ||
| /// | ||
| /// User code accesses this state via the corresponding ``RequestContext`` | ||
| /// capabilities (``HTTPServerCapability/ConnectionInfo``, | ||
| /// ``HTTPServerCapability/PeerCertificate``) when handling individual | ||
| /// ``HTTPServerCapability/PeerCertificate``, | ||
| /// ``HTTPServerCapability/ConnectionEvents``) when handling individual | ||
| /// requests, and directly when implementing an | ||
| /// ``NIOHTTPServerConnectionHandler``. | ||
| public struct ConnectionContext: Sendable { | ||
| /// The application-level HTTP version negotiated for this connection. | ||
| public let httpVersion: HTTPVersion | ||
|
|
||
| /// The peer's address, when known. | ||
| public let remoteAddress: NIOHTTPServer.SocketAddress? | ||
| /// | ||
| /// Over HTTP/3 this can change during the connection's lifetime, when the client migrates the connection to a | ||
| /// new network path. ``connectionEvents`` reports each change. | ||
| public var remoteAddress: NIOHTTPServer.SocketAddress? { | ||
| self.currentRemoteAddress.withLockedValue { $0 } | ||
| } | ||
|
|
||
| /// The local address the connection is bound to, when known. | ||
| public let localAddress: NIOHTTPServer.SocketAddress? | ||
|
|
@@ -58,16 +65,42 @@ extension NIOHTTPServer { | |
| /// derived validated chain. | ||
| public var validatedPeerCertificateChain: X509.ValidatedCertificateChain? | ||
|
|
||
| /// The events that happen on this connection, such as the peer's address changing. | ||
| /// | ||
| /// The sequence finishes once the connection closes. See ``ConnectionEvents``. | ||
| public let connectionEvents: ConnectionEvents | ||
|
|
||
| /// Shared by every copy of this context, so that over HTTP/3 the connection's ``ConnectionEventsHandler`` keeps | ||
| /// them all current. | ||
| private let currentRemoteAddress: NIOLockedValueBox<NIOHTTPServer.SocketAddress?> | ||
|
|
||
| init( | ||
| httpVersion: HTTPVersion, | ||
| remoteAddress: NIOHTTPServer.SocketAddress? = nil, | ||
| localAddress: NIOHTTPServer.SocketAddress? = nil, | ||
| validatedPeerCertificateChain: X509.ValidatedCertificateChain? = nil | ||
| ) { | ||
| self.httpVersion = httpVersion | ||
| self.remoteAddress = remoteAddress | ||
| self.currentRemoteAddress = NIOLockedValueBox(remoteAddress) | ||
| self.localAddress = localAddress | ||
| self.validatedPeerCertificateChain = validatedPeerCertificateChain | ||
| self.connectionEvents = ConnectionEvents(broadcaster: ConnectionEventBroadcaster()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should avoid initialising this over HTTP/1.1 and HTTP/2. |
||
| } | ||
|
|
||
| /// Applies an `event` received on the connection to this context, then delivers it to every iterator of | ||
| /// ``connectionEvents``. | ||
| func connectionEventReceived(_ event: ConnectionEvent) { | ||
| switch event { | ||
| case .remoteAddressChanged(let address): | ||
| self.currentRemoteAddress.withLockedValue { $0 = address } | ||
| } | ||
|
|
||
| self.connectionEvents.broadcaster.yield(event) | ||
| } | ||
|
|
||
| /// Ends every iterator of ``connectionEvents``, now that the connection has closed. | ||
| func unsubscribeFromConnectionEvents() { | ||
| self.connectionEvents.broadcaster.finish() | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| //===----------------------------------------------------------------------===// | ||
| // | ||
| // This source file is part of the Swift HTTP Server open source project | ||
| // | ||
| // Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors | ||
| // Licensed under Apache License v2.0 | ||
| // | ||
| // See LICENSE.txt for license information | ||
| // See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| //===----------------------------------------------------------------------===// | ||
|
|
||
| import Synchronization | ||
|
|
||
| @available(anyAppleOS 27.0, *) | ||
| extension NIOHTTPServer { | ||
| /// Something that happened on a connection, as reported by ``ConnectionEvents``. | ||
| @nonexhaustive | ||
| public enum ConnectionEvent: Sendable, Hashable { | ||
| /// The peer's address changed. | ||
| /// | ||
| /// Over HTTP/3, a client can migrate its QUIC connection to a new network path (RFC 9000 § 9): for example, | ||
| /// when it moves from Wi-Fi to cellular, or when a NAT rebinds the port it is reachable on. The associated value | ||
| /// is the peer's new address, which ``ConnectionContext/remoteAddress`` reports from then on. | ||
| case remoteAddressChanged(NIOHTTPServer.SocketAddress) | ||
| } | ||
|
|
||
| /// The events that happen on a connection. | ||
| /// | ||
| /// Any number of tasks can iterate this sequence concurrently: for example, a ``NIOHTTPServerConnectionHandler`` | ||
| /// alongside the request handlers of every request in flight on the connection. Each iterator receives every event | ||
| /// that happens after it is created, and finishes once the connection closes. | ||
| /// | ||
| /// An iterator that falls behind keeps only the most recent events, dropping older ones. The connection's state, | ||
| /// such as ``ConnectionContext/remoteAddress``, is always current, so read it rather than relying on receiving every | ||
| /// event. | ||
| /// | ||
| /// ```swift | ||
| /// for await event in requestContext.connectionEvents { | ||
| /// switch event { | ||
| /// case .remoteAddressChanged(let address): | ||
| /// logger.info("Client migrated", metadata: ["address": "\(address)"]) | ||
| /// default: | ||
| /// break | ||
| /// } | ||
| /// } | ||
| /// ``` | ||
| public struct ConnectionEvents: AsyncSequence, Sendable { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not a big fan of modeling this with an asynchronous sequence. I would rather like to see this modeled as: withConnectionEventHandler {
// handle request
} onEvent { event in
}The benefit of this is that it can be implemented a lot simpler than an asynchronous sequences. In particular, we don't have to buffer events. It makes the entire surface below with the broadcaster also go away. Users can still create asynchronous sequences out of the events themselves.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would you call this from within the request handler? How does it make the broadcaster go away? We still need to translate connection channel events into something that is received by all requests being handled on that connection, right?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes inside a request handler you would do the above. This follows the structured eventing pattern of We need something similar to the |
||
| let broadcaster: ConnectionEventBroadcaster | ||
|
|
||
| init(broadcaster: ConnectionEventBroadcaster) { | ||
| self.broadcaster = broadcaster | ||
| } | ||
|
|
||
| public func makeAsyncIterator() -> AsyncIterator { | ||
| AsyncIterator(base: self.broadcaster.subscribe().makeAsyncIterator()) | ||
| } | ||
|
|
||
| /// An iterator over a connection's events. | ||
| public struct AsyncIterator: AsyncIteratorProtocol { | ||
| private var base: AsyncStream<ConnectionEvent>.Iterator | ||
|
|
||
| init(base: AsyncStream<ConnectionEvent>.Iterator) { | ||
| self.base = base | ||
| } | ||
|
|
||
| /// Returns the next event, or `nil` once the connection has closed or the current task is cancelled. | ||
| public mutating func next(isolation actor: isolated (any Actor)?) async -> ConnectionEvent? { | ||
| await self.base.next(isolation: actor) | ||
| } | ||
|
|
||
| /// Returns the next event, or `nil` once the connection has closed or the current task is cancelled. | ||
| public mutating func next() async -> ConnectionEvent? { | ||
| await self.base.next(isolation: #isolation) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(*, unavailable) | ||
| extension NIOHTTPServer.ConnectionEvents.AsyncIterator: Sendable {} | ||
|
|
||
| /// Delivers each of a connection's events to every ``NIOHTTPServer/ConnectionEvents`` iterator that exists when it | ||
| /// happens. | ||
| /// | ||
| /// This is what lets several handlers iterate a connection's events concurrently: the iterators of a single | ||
| /// `AsyncStream` would instead compete for its elements, with each event reaching only one of them. | ||
| @available(anyAppleOS 27.0, *) | ||
| final class ConnectionEventBroadcaster: Sendable { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wondering if it might be better to broadcast connection events to streams at the swift-nio-http3 layer.
In apple/swift-nio-http3#72 for example, this property is used to inform all open outbound request streams about whether the server supports Extended CONNECT (the client only finds out once the server sends their SETTINGS).
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we went this route, we'd have to do this at the h2 layer too though right? |
||
| /// How many undelivered events each iterator buffers before dropping the oldest. | ||
| static let bufferedEventLimit = 16 | ||
|
|
||
| private struct State { | ||
| var subscribers: [Int: AsyncStream<NIOHTTPServer.ConnectionEvent>.Continuation] = [:] | ||
| var nextSubscriberID = 0 | ||
| var isFinished = false | ||
| } | ||
|
|
||
| private let state = Mutex(State()) | ||
|
|
||
| /// The number of iterators currently receiving events. | ||
| var subscriberCount: Int { | ||
| self.state.withLock { $0.subscribers.count } | ||
| } | ||
|
|
||
| /// Returns a stream of the events that happen from now on, which finishes once ``finish()`` is called. | ||
| func subscribe() -> AsyncStream<NIOHTTPServer.ConnectionEvent> { | ||
| let (stream, continuation) = AsyncStream.makeStream( | ||
| of: NIOHTTPServer.ConnectionEvent.self, | ||
| bufferingPolicy: .bufferingNewest(Self.bufferedEventLimit) | ||
| ) | ||
|
|
||
| let subscriberID: Int? = self.state.withLock { state in | ||
| guard !state.isFinished else { return nil } | ||
| let subscriberID = state.nextSubscriberID | ||
| state.nextSubscriberID += 1 | ||
| state.subscribers[subscriberID] = continuation | ||
| return subscriberID | ||
| } | ||
|
|
||
| guard let subscriberID else { | ||
| // The connection has already closed, so there is nothing left to report. | ||
| continuation.finish() | ||
| return stream | ||
| } | ||
|
|
||
| // Stop delivering to an iterator once it is dropped, or the task iterating it is cancelled. | ||
| continuation.onTermination = { [weak self] _ in | ||
| self?.state.withLock { _ = $0.subscribers.removeValue(forKey: subscriberID) } | ||
| } | ||
|
|
||
| return stream | ||
| } | ||
|
|
||
| /// Delivers `event` to every subscribed iterator. | ||
| func yield(_ event: NIOHTTPServer.ConnectionEvent) { | ||
| self.state.withLock { state in | ||
| for subscriber in state.subscribers.values { | ||
| subscriber.yield(event) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Ends every subscribed iterator, and any that subscribe afterwards. | ||
| func finish() { | ||
| let subscribers = self.state.withLock { state in | ||
| state.isFinished = true | ||
| let subscribers = state.subscribers | ||
| state.subscribers = [:] | ||
| return subscribers | ||
| } | ||
|
|
||
| // Outside the lock: finishing a stream runs its termination handler, which takes the lock. | ||
| for subscriber in subscribers.values { | ||
| subscriber.finish() | ||
| } | ||
| } | ||
| } | ||
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 can reuse
HTTP3ConnectionManagerhere instead of defining a new handler.