Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions Sources/NIOHTTPServer/ConnectionEventsHandler.swift
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can reuse HTTP3ConnectionManager here instead of defining a new handler.

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
Expand Up @@ -25,6 +25,9 @@ extension HTTPServerCapability {
/// underlying transport cannot report an address.
public protocol ConnectionInfo: RequestContext {
/// The peer's address, when known.
///
/// This can change during the connection's lifetime: for example, when a client migrates an HTTP/3 connection
/// to a new network path.
var remoteAddress: NIOHTTPServer.SocketAddress? { get }

/// The local address the connection is bound to, when known.
Expand All @@ -41,4 +44,17 @@ extension HTTPServerCapability {
/// The peer's validated certificate chain, when available.
var validatedPeerCertificateChain: X509.ValidatedCertificateChain? { get }
}

/// A request-context capability exposing the events that happen on the connection carrying the request.
///
/// Servers whose request context conforms to this capability let request
/// handlers react to changes in the connection beneath them, such as the
/// peer's address changing when a client migrates an HTTP/3 connection to
/// a new network path.
public protocol ConnectionEvents: RequestContext {
/// The events that happen on the connection carrying the request.
///
/// The sequence finishes once the connection closes.
var connectionEvents: NIOHTTPServer.ConnectionEvents { get }
}
}
41 changes: 37 additions & 4 deletions Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//
//===----------------------------------------------------------------------===//

import NIOConcurrencyHelpers
import NIOCore
import NIOSSL
public import X509
Expand All @@ -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?
Expand All @@ -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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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()
}
}
}
160 changes: 160 additions & 0 deletions Sources/NIOHTTPServer/NIOHTTPServer+ConnectionEvents.swift
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 withTaskCancellationHandler and withGracefulShutdown handler.

We need something similar to the GracefulShutdownManager of swift-service-lifecycle but we don't need to broadcast. It just stores the handler closures.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

HTTP3ConnectionCoordinator holds a reference to all streams.

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).

@gjcairo gjcairo Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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?
Not saying we should or shouldn't, I'm still undecided. But I'm not sure where the responsibility lies really. This is a connection-level event after all. Not sure broadcasting it to all streams would make sense.

/// 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()
}
}
}
Loading
Loading