From f3eedc4634f096dc4d8805a5be3b10dd91a41dbe Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 30 Jun 2026 11:13:10 +0100 Subject: [PATCH 1/8] Add Connection Lifecycle handling --- Package.swift | 21 +- .../ConnectionHandlerExample.swift | 89 ++ .../LogTracer.swift | 50 +- ...sportSecurity+MTLSTrustConfiguration.swift | 3 +- .../NIOHTTPServer/HTTPKeepAliveHandler.swift | 71 +- ...verCapability+ConnectionCapabilities.swift | 61 ++ .../NIOHTTPServer+Connection.swift | 126 +++ .../NIOHTTPServer+ConnectionContext.swift | 82 +- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 182 +++- .../NIOHTTPServer+ListeningAddress.swift | 2 +- .../NIOHTTPServer+RequestContext.swift | 83 ++ .../NIOHTTPServer+SecureUpgrade.swift | 217 ++-- Sources/NIOHTTPServer/NIOHTTPServer.swift | 104 +- ...IOHTTPServerClosureConnectionHandler.swift | 33 + .../NIOHTTPServerConnectionHandler.swift | 45 + ...IOHTTPServerDefaultConnectionHandler.swift | 46 + Sources/NIOHTTPServer/ServerChannel.swift | 2 +- .../RequestHandlerExample.swift} | 18 +- .../ConnectionLifecycleTests.swift | 955 ++++++++++++++++++ .../NIOHTTPServerTests.swift | 2 +- Tests/NIOHTTPServerTests/TestError.swift | 1 + .../NIOHTTPServer+HTTP1.swift | 12 +- .../NIOHTTPServer+SecureUpgrade.swift | 5 +- .../TestingChannelServer+HTTP1.swift | 2 +- 24 files changed, 1971 insertions(+), 241 deletions(-) create mode 100644 Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift rename Sources/{Example => ExampleSupport}/LogTracer.swift (60%) create mode 100644 Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift rename Sources/{Example/Example.swift => RequestHandlerExample/RequestHandlerExample.swift} (78%) create mode 100644 Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift diff --git a/Package.swift b/Package.swift index dee08dc..eec3f26 100644 --- a/Package.swift +++ b/Package.swift @@ -63,10 +63,27 @@ let package = Package( .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.11.0"), ], targets: [ - .executableTarget( - name: "Example", + .target( + name: "ExampleSupport", dependencies: [ .product(name: "Tracing", package: "swift-distributed-tracing"), + ], + swiftSettings: extraSettings + ), + .executableTarget( + name: "RequestHandlerExample", + dependencies: [ + "ExampleSupport", + .product(name: "Instrumentation", package: "swift-distributed-tracing"), + .product(name: "Logging", package: "swift-log"), + "NIOHTTPServer", + ], + swiftSettings: extraSettings + ), + .executableTarget( + name: "ConnectionHandlerExample", + dependencies: [ + "ExampleSupport", .product(name: "Instrumentation", package: "swift-distributed-tracing"), .product(name: "Logging", package: "swift-log"), "NIOHTTPServer", diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift new file mode 100644 index 0000000..3460eb1 --- /dev/null +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// 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 BasicContainers +import Crypto +import ExampleSupport +import Foundation +import Instrumentation +import Logging +import NIOHTTPServer +import X509 + +@main +@available(anyAppleOS 26.0, *) +struct ConnectionHandlerExample { + static func main() async throws { + try await serve() + } + + @concurrent + static func serve() async throws { + InstrumentationSystem.bootstrap(LogTracer()) + let rootLogger: Logger = { + var logger = Logger(label: "ConnectionHandlerExample") + logger.logLevel = .trace + return logger + }() + + let privateKey = P256.Signing.PrivateKey() + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12346), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) + ) + ) + ) + + // A connection handler runs once per accepted connection and drives the + // request loop via `connection.handleRequests`. Both the connection + // handler and the request handler are inline closures. + try await server.serve { connection, context in + let connectionLogger: Logger = { + var logger = rootLogger + let peer = context.remoteAddress.map { "\($0)" } ?? "unknown" + logger[metadataKey: "peer"] = .string(peer) + logger[metadataKey: "http"] = .string("\(context.httpVersion)") + return logger + }() + connectionLogger.info("connection accepted") + defer { connectionLogger.info("connection closed") } + + try await connection.handleRequests { request, _, _, responseSender in + connectionLogger.info("request received: \(request.path ?? "")") + var body = UniqueArray(copying: "Well, hello!".utf8) + try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) + } + } + } +} diff --git a/Sources/Example/LogTracer.swift b/Sources/ExampleSupport/LogTracer.swift similarity index 60% rename from Sources/Example/LogTracer.swift rename to Sources/ExampleSupport/LogTracer.swift index 5c1bf8d..9b04d61 100644 --- a/Sources/Example/LogTracer.swift +++ b/Sources/ExampleSupport/LogTracer.swift @@ -12,14 +12,14 @@ // //===----------------------------------------------------------------------===// -import Tracing +package import Tracing -struct LogTracer: Tracer { - typealias Span = NoOpSpan +package struct LogTracer: Tracer { + package typealias Span = NoOpSpan - init() {} + package init() {} - func startAnySpan( + package func startAnySpan( _ operationName: String, context: @autoclosure () -> ServiceContext, ofKind kind: SpanKind, @@ -32,16 +32,20 @@ struct LogTracer: Tracer { return NoOpSpan(context: context()) } - func forceFlush() { + package func forceFlush() { print("Flushing") } - func inject(_ context: ServiceContext, into carrier: inout Carrier, using injector: Inject) + package func inject( + _ context: ServiceContext, + into carrier: inout Carrier, + using injector: Inject + ) where Inject: Injector, Carrier == Inject.Carrier { // no-op } - func extract( + package func extract( _ carrier: Carrier, into context: inout ServiceContext, using extractor: Extract @@ -50,47 +54,43 @@ struct LogTracer: Tracer { // no-op } - struct NoOpSpan: Tracing.Span { - let context: ServiceContext - var isRecording: Bool { + package struct NoOpSpan: Tracing.Span { + package let context: ServiceContext + package var isRecording: Bool { false } - var operationName: String { - get { - "noop" - } + package var operationName: String { + get { "noop" } nonmutating set { // ignore } } - init(context: ServiceContext) { + package init(context: ServiceContext) { self.context = context } - func setStatus(_ status: SpanStatus) {} + package func setStatus(_ status: SpanStatus) {} - func addLink(_ link: SpanLink) {} + package func addLink(_ link: SpanLink) {} - func addEvent(_ event: SpanEvent) {} + package func addEvent(_ event: SpanEvent) {} - func recordError( + package func recordError( _ error: any Error, attributes: SpanAttributes, at instant: @autoclosure () -> Instant ) {} - var attributes: SpanAttributes { - get { - [:] - } + package var attributes: SpanAttributes { + get { [:] } nonmutating set { // ignore } } - func end(at instant: @autoclosure () -> Instant) { + package func end(at instant: @autoclosure () -> Instant) { print("Ending span") } } diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift index 4e5fec7..764a390 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift @@ -84,8 +84,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, /// optionally including the validated certificate chain you derived. Returning the validated certificate /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through - /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local - /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``NIOHTTPServer/RequestContext/peerCertificateChain``. Otherwise, return /// ``CertificateVerificationResult/failed(_:)`` if verification fails. /// - certificateVerification: The client certificate verification behavior. Defaults to /// ``CertificateVerificationMode/noHostnameVerification``. diff --git a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift index 26f7cb6..7d0a970 100644 --- a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift +++ b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift @@ -12,11 +12,15 @@ // //===----------------------------------------------------------------------===// +import NIOConcurrencyHelpers import NIOCore import NIOHTTPTypes -/// A NIO channel handler that ensures HTTP/1.1 keep-alive semantics are honored when -/// the server starts writing a response before the request body has been fully read. +/// A NIO channel handler that ensures HTTP/1.1 keep-alive semantics are honored +/// when the server starts writing a response before the request body has been +/// fully read, and that reacts to the connection-scoped close flag by amending +/// the next response head with `Connection: close` and closing the channel after +/// the response is written. /// /// The handler buffers final response parts (head + any body fragments + end) when /// they are written before the request `.end` has been received. The buffer is @@ -26,13 +30,15 @@ import NIOHTTPTypes /// - **`flush`**: an upstream writer (e.g. `NIOAsyncChannelOutboundWriter`) forced a /// flush. /// -/// At each deadline, if request `.end` has arrived, the buffer is flushed as-is and -/// the connection is reusable. If request `.end` has *not* arrived, the head is -/// amended with `Connection: close`, the buffer is flushed, and the connection is -/// closed once response `.end` is written. This protects against clients that keep -/// uploading request body bytes after the response has completed (which would -/// otherwise force the server to drain unbounded data) and gives the client an -/// explicit signal not to pipeline another request on the connection. +/// At each deadline, if request `.end` has arrived and no close has been +/// signalled, the buffer is flushed as-is and the connection is reusable. If +/// request `.end` has *not* arrived, or the shared close flag has been set, the +/// head is amended with `Connection: close`, the buffer is flushed, and the +/// connection is closed once response `.end` is written. This protects against +/// clients that keep uploading request body bytes after the response has +/// completed (which would otherwise force the server to drain unbounded data) +/// and gives the client an explicit signal not to pipeline another request on +/// the connection. /// /// Informational (1xx) responses pass through unchanged and do not affect buffering /// state. @@ -71,12 +77,27 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { /// `true` if we've committed to closing the connection after this response's /// `.end` is written. Set when the buffer is flushed while request `.end` has - /// not yet arrived (so we add `Connection: close`). Cleared when a new request - /// begins. + /// not yet arrived (so we add `Connection: close`), or when the close flag + /// was set by ``NIOHTTPServer/ConnectionContext/signalConnectionClose()`` + /// while a response is in flight. Cleared when a new request begins. private var closeAfterResponseEnd: Bool = false private var finalResponseState: FinalResponseState = .notStarted + /// Shared close flag. ``NIOHTTPServer/ConnectionContext/signalConnectionClose()`` + /// sets this synchronously; the handler observes it when writing the next + /// response head and amends with `Connection: close`, then closes the channel + /// after the response `.end`. + let closeFlag: NIOLockedValueBox + + init(closeFlag: NIOLockedValueBox) { + self.closeFlag = closeFlag + } + + private var closeSignalled: Bool { + self.closeFlag.withLockedValue { $0 } + } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { let part = self.unwrapInboundIn(data) switch part { @@ -118,9 +139,19 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { return } if self.requestEndReceived { - // Request fully read; stream the response directly. + // Request fully read; stream the response directly. If a close + // signal has been observed, amend the head with `Connection: close` + // and arrange to close once response `.end` is written. + let outboundPart: HTTPResponsePart + if self.closeSignalled, case .head(var response) = part { + response.headerFields[.connection] = "close" + outboundPart = .head(response) + self.closeAfterResponseEnd = true + } else { + outboundPart = part + } self.finalResponseState = .streaming - context.write(data, promise: promise) + context.write(self.wrapOutboundOut(outboundPart), promise: promise) } else { // Start buffering with the head. Additional parts (body, end) the // handler may write before the next deadline are appended below. @@ -135,8 +166,9 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { case .streaming: context.write(data, promise: promise) if case .end = part, self.closeAfterResponseEnd { - // The head we flushed earlier carried `Connection: close`; close - // the connection now that the response is complete. + // The head we flushed earlier carried `Connection: close`, or a + // close signal arrived after the head was flushed; close the + // connection now that the response is complete. context.flush() context.close(mode: .output, promise: nil) } @@ -146,7 +178,7 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { func flush(context: ChannelHandlerContext) { // An upstream writer forced a flush. Same deadline as `channelReadComplete`: // release any buffered parts, with `Connection: close` if request `.end` - // hasn't arrived. + // hasn't arrived or close was signalled. if case .buffering = self.finalResponseState { self.flushBuffer(context: context) } @@ -154,12 +186,13 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { } /// Releases buffered response parts to the pipeline. If request `.end` has not - /// yet arrived, amend the head with `Connection: close` and arrange to close - /// the connection once response `.end` is written. + /// yet arrived, or if the shared close flag is set, amend the head with + /// `Connection: close` and arrange to close the connection once response `.end` + /// is written. private func flushBuffer(context: ChannelHandlerContext) { guard case .buffering(var head, let additional) = self.finalResponseState else { return } - if !self.requestEndReceived { + if !self.requestEndReceived || self.closeSignalled { // Amend the head with `Connection: close` before flushing. if case .head(var response) = head.part { response.headerFields[.connection] = "close" diff --git a/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift new file mode 100644 index 0000000..1515b69 --- /dev/null +++ b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public import HTTPAPIs +public import X509 + +@available(anyAppleOS 26.0, *) +extension HTTPServerCapability { + /// A request-context capability exposing connection-scoped peer and local addresses. + /// + /// Servers whose request context conforms to this capability surface the + /// peer's address and the local address the connection is bound to. Both + /// are reported best-effort: implementations may return `nil` when the + /// underlying transport cannot report an address. + public protocol ConnectionInfo: RequestContext { + /// The peer's address, when known. + var remoteAddress: NIOHTTPServer.SocketAddress? { get } + + /// The local address the connection is bound to, when known. + var localAddress: NIOHTTPServer.SocketAddress? { get } + } + + /// A request-context capability exposing the validated peer certificate chain. + /// + /// Servers whose request context conforms to this capability surface the + /// peer's mTLS-validated certificate chain (when applicable). Implementations + /// return `nil` if mTLS isn't configured, or if no validated chain was + /// derived. + public protocol PeerCertificate: RequestContext { + /// The peer's validated certificate chain, when available. + var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws } + } + + /// A request-context capability that lets a request handler signal that the + /// connection should close after the current response. + /// + /// Servers whose request context conforms to this capability allow handlers + /// to indicate that the underlying connection should be closed once the + /// in-flight response has been sent. Implementations make the signal + /// effective on a best-effort basis (HTTP/1.1 typically appends + /// `Connection: close` and closes the channel; HTTP/2 typically sends + /// `GOAWAY` and lets in-flight streams complete normally). + public protocol CloseableConnection: RequestContext { + /// Signal that the connection should close after the current response. + /// + /// Non-blocking and idempotent. Subsequent calls have no additional + /// effect. + func signalConnectionClose() + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift new file mode 100644 index 0000000..a4fa8f6 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import NIOHTTP2 +import NIOHTTPTypes + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// An active HTTP server connection. + /// + /// A `Connection` is owned by exactly one ``NIOHTTPServerConnectionHandler/handleConnection(connection:context:)`` + /// invocation. The handler typically passes it to ``handleRequests(handler:)``, + /// which runs the request loop until the peer closes the connection, the + /// server shuts down, the handler signals close via + /// ``ConnectionContext/signalConnectionClose()``, or an error occurs. + /// + /// A handler that decides to terminate before any request can simply + /// return without calling ``handleRequests(handler:)``: the `Connection` + /// is dropped on scope exit and the underlying channel is torn down + /// cleanly by the server. + /// + /// Connection-scoped data is exposed on the accompanying + /// ``ConnectionContext`` passed alongside the connection to the handler. + public struct Connection: ~Copyable, Sendable { + /// Per-protocol state. HTTP/1.1 carries the request-channel's already-running + /// inbound stream and outbound writer (the dispatcher owns the channel and + /// drives `executeThenClose`, so the writer is finished cleanly even if the + /// connection handler returns without calling ``handleRequests(handler:)``). + /// HTTP/2 carries the connection channel and stream multiplexer. + enum HTTPProtocol: Sendable { + case http1_1( + inbound: NIOAsyncChannelInboundStream, + outbound: NIOAsyncChannelOutboundWriter + ) + case http2( + connectionChannel: any Channel, + multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer> + ) + } + + let server: NIOHTTPServer + let context: ConnectionContext + let httpProtocol: HTTPProtocol + + init(server: NIOHTTPServer, context: ConnectionContext, httpProtocol: HTTPProtocol) { + self.server = server + self.context = context + self.httpProtocol = httpProtocol + } + + /// Run the request loop on this connection until it terminates. + /// + /// Each request received on this connection is dispatched to `handler`. The + /// loop returns when the peer closes the connection, the server shuts down, + /// the handler signals close via ``ConnectionContext/signalConnectionClose()``, + /// or an error occurs. + public consuming func handleRequests( + handler: Handler + ) async throws + where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender + { + let server = self.server + let context = self.context + switch self.httpProtocol { + case .http1_1(let inbound, let outbound): + await server.handleHTTP1RequestLoop( + inbound: inbound, + outbound: outbound, + handler: handler, + context: context + ) + case .http2(let connectionChannel, let multiplexer): + await server.handleHTTP2Connection( + connectionChannel: connectionChannel, + multiplexer: multiplexer, + handler: handler, + context: context + ) + } + } + + /// Convenience overload accepting a closure instead of a + /// ``HTTPServerRequestHandler`` conformance. + /// + /// ```swift + /// try await server.serve { connection, context in + /// try await connection.handleRequests { request, requestContext, reader, responseSender in + /// var body = UniqueArray(copying: "Hello, World!".utf8) + /// try await responseSender.sendAndFinish(.init(status: .ok), buffer: &body) + /// } + /// } + /// ``` + public consuming func handleRequests( + handler: + @Sendable @escaping ( + _ request: HTTPRequest, + _ requestContext: consuming NIOHTTPServer.RequestContext, + _ reader: consuming sending NIOHTTPServer.Reader, + _ responseSender: consuming sending NIOHTTPServer.ResponseSender + ) async throws -> Void + ) async throws { + try await self.handleRequests( + handler: HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + >(handler: handler) + ) + } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 38fcfe2..6a9123a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -12,25 +12,78 @@ // //===----------------------------------------------------------------------===// +import NIOConcurrencyHelpers import NIOCore import NIOSSL public import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { - /// Connection-specific information available during request handling. + /// The application-level HTTP version negotiated for a connection. + public enum HTTPVersion: Sendable, Hashable { + case http1_1 + case http2 + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// Connection-scoped state. + /// + /// Carries connection-scoped data such as the negotiated HTTP version, the + /// peer / local addresses, the peer's validated certificate chain (when + /// applicable), and the close-signal trigger that + /// ``signalConnectionClose()`` invokes. /// - /// Provides access to data such as the peer's validated certificate chain. + /// User code accesses this state via the corresponding ``RequestContext`` + /// capabilities (``HTTPServerCapability/ConnectionInfo``, + /// ``HTTPServerCapability/PeerCertificate``, + /// ``HTTPServerCapability/CloseableConnection``) 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? + + /// The local address the connection is bound to, when known. + public let localAddress: NIOHTTPServer.SocketAddress? + var peerCertificateChainFuture: EventLoopFuture? - init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { + /// Per-protocol close mechanism. HTTP/1.1 uses a shared atomic flag the + /// channel's ``HTTPKeepAliveHandler`` reads when writing the next + /// response head. HTTP/2 fires `ChannelShouldQuiesceEvent` on the + /// connection channel; NIO's `NIOHTTP2ServerConnectionManagementHandler` + /// reacts by sending `GOAWAY`, letting in-flight streams complete + /// normally, and finally closing the connection. + let closeBacking: CloseBacking + + enum CloseBacking: Sendable { + case http1_1(closeFlag: NIOLockedValueBox) + case http2(connectionChannel: any Channel) + } + + init( + httpVersion: HTTPVersion, + remoteAddress: NIOHTTPServer.SocketAddress? = nil, + localAddress: NIOHTTPServer.SocketAddress? = nil, + peerCertificateChainFuture: EventLoopFuture? = nil, + closeBacking: CloseBacking + ) { + self.httpVersion = httpVersion + self.remoteAddress = remoteAddress + self.localAddress = localAddress self.peerCertificateChainFuture = peerCertificateChainFuture + self.closeBacking = closeBacking } - /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set - /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the - /// derived validated chain. + /// The peer's validated certificate chain. Returns `nil` if a custom + /// verification callback was not set when configuring mTLS in the + /// server configuration, or if the custom verification callback did not + /// return the derived validated chain. public var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws { if let certs = try await self.peerCertificateChainFuture?.get() { @@ -39,5 +92,22 @@ extension NIOHTTPServer { return nil } } + + /// Signal that the connection should close after the current response. + /// + /// Non-blocking and idempotent. Effective after the current response: + /// + /// - On HTTP/1.1, the response carries `Connection: close` and the + /// channel is closed once the response has been written. + /// - On HTTP/2, the connection sends `GOAWAY`; in-flight streams + /// complete normally before the connection closes. + public func signalConnectionClose() { + switch self.closeBacking { + case .http1_1(let closeFlag): + closeFlag.withLockedValue { $0 = true } + case .http2(let connectionChannel): + connectionChannel.pipeline.fireUserInboundEventTriggered(ChannelShouldQuiesceEvent()) + } + } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 349116c..15ffcee 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -13,15 +13,25 @@ //===----------------------------------------------------------------------===// import Logging +import NIOConcurrencyHelpers import NIOCore import NIOExtras import NIOHTTP1 import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix +import NIOSSL @available(anyAppleOS 26.0, *) extension NIOHTTPServer { + /// An HTTP/1.1 connection vended by the accept loop: the async channel and + /// the close flag the channel's ``HTTPKeepAliveHandler`` shares with the + /// connection context. + struct HTTP1ChildConnection: Sendable { + let asyncChannel: NIOAsyncChannel + let closeFlag: NIOLockedValueBox + } + /// Serves incoming plaintext HTTP/1.1 connections. /// /// Each connection is handled concurrently in its own child task. Individual connection errors are handled within @@ -29,21 +39,13 @@ extension NIOHTTPServer { /// /// - Parameters: /// - serverChannel: The async channel that produces incoming HTTP/1.1 connections. - /// - handler: The request handler. + /// - connectionHandler: The connection handler invoked for each accepted connection. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveInsecureHTTP1_1( - serverChannel: NIOAsyncChannel, Never>, - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + func serveInsecureHTTP1_1( + serverChannel: NIOAsyncChannel, + connectionHandler: Handler + ) async throws { try await serverChannel.executeThenClose { inbound in // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child // task would immediately propagate upwards, cancelling all child tasks and bringing down the entire server. @@ -51,9 +53,12 @@ extension NIOHTTPServer { // `inbound`) must be caught and handled directly. let inboundConnectionIterationError = await withDiscardingTaskGroup { group -> (any Error)? in do { - for try await http1Channel in inbound { + for try await child in inbound { group.addTask { - await self.handleHTTP1RequestChannel(channel: http1Channel, handler: handler) + await self.dispatchPlaintextHTTP1_1Connection( + child: child, + connectionHandler: connectionHandler + ) } } @@ -70,16 +75,56 @@ extension NIOHTTPServer { } } + /// Builds the per-connection ``Connection`` and ``ConnectionContext`` for a + /// plaintext HTTP/1.1 child channel and dispatches to the connection + /// handler. Errors from the connection handler are logged. + /// + /// The dispatcher owns the channel's `executeThenClose` so the + /// `NIOAsyncWriter` is finished cleanly whether or not the connection + /// handler called ``Connection/handleRequests(handler:)``. + private func dispatchPlaintextHTTP1_1Connection( + child: sending HTTP1ChildConnection, + connectionHandler: Handler + ) async { + do { + try await child.asyncChannel.executeThenClose { inbound, outbound in + let context = NIOHTTPServer.makeHTTP1ConnectionContext( + requestChannel: child.asyncChannel, + closeFlag: child.closeFlag, + peerCertificateChainFuture: nil + ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + ) + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } + } + } catch { + self.logger.debug( + "Error tearing down HTTP/1.1 channel", + metadata: ["error": "\(error)"] + ) + } + } + func setupHTTP1_1ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget] ) async throws -> [( - NIOAsyncChannel, Never>, ServerQuiescingHelper + NIOAsyncChannel, ServerQuiescingHelper )] { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) var serverChannels = [ - (NIOAsyncChannel, Never>, ServerQuiescingHelper) + (NIOAsyncChannel, ServerQuiescingHelper) ]() do { @@ -122,64 +167,99 @@ extension NIOHTTPServer { return serverChannels } + /// Configures the HTTP/1.1 server pipeline and the keep-alive handler, sharing + /// a fresh close flag between the keep-alive handler (which observes it when + /// writing the next response head) and the eventually-vended ``HTTP1ChildConnection``. func setupHTTP1_1Connection( channel: any Channel, asyncChannelConfiguration: NIOAsyncChannel.Configuration, isSecure: Bool - ) -> EventLoopFuture> { - channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { + ) -> EventLoopFuture { + let closeFlag = NIOLockedValueBox(false) + return channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: isSecure)) - try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) + try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler(closeFlag: closeFlag)) - return try NIOAsyncChannel( + let asyncChannel = try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: asyncChannelConfiguration ) + return HTTP1ChildConnection(asyncChannel: asyncChannel, closeFlag: closeFlag) } } - /// Handles an HTTP/1.1 connection channel, which may carry multiple serial requests on the - /// same connection (keep-alive). - func handleHTTP1RequestChannel( - channel: NIOAsyncChannel, - handler: Handler + /// Builds a ``ConnectionContext`` for an HTTP/1.1 request channel. + /// + /// The context's ``ConnectionContext/signalConnectionClose()`` synchronously + /// flips the shared close flag the channel's ``HTTPKeepAliveHandler`` + /// observes when writing the next response head. The synchronous set + /// side-steps any race between firing a NIO event off-loop and writing the + /// response head off-loop. The handler reacts by amending the next response + /// head with `Connection: close` and closing the channel once the response + /// `.end` is written. + static func makeHTTP1ConnectionContext( + requestChannel: NIOAsyncChannel, + closeFlag: NIOLockedValueBox, + peerCertificateChainFuture: EventLoopFuture? + ) -> ConnectionContext { + ConnectionContext( + httpVersion: .http1_1, + remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), + peerCertificateChainFuture: peerCertificateChainFuture, + closeBacking: .http1_1(closeFlag: closeFlag) + ) + } + + /// Drives the request loop on an HTTP/1.1 connection that may carry + /// multiple serial requests (keep-alive). Invoked from + /// ``NIOHTTPServer/Connection/handleRequests(handler:)`` for the + /// HTTP/1.1 case. + /// + /// The caller (the dispatcher) owns the channel's `executeThenClose`, + /// so this method only iterates inbound requests and writes responses; + /// it never closes the channel itself. The loop terminates when the + /// peer closes the connection, the task is cancelled, the handler + /// signals close (which causes the ``HTTPKeepAliveHandler`` to close + /// the channel after the response — the next iterator read then returns + /// `nil`), or an error occurs. + func handleHTTP1RequestLoop( + inbound: NIOAsyncChannelInboundStream, + outbound: NIOAsyncChannelOutboundWriter, + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { do { - try await channel.executeThenClose { inbound, outbound in - var iterator = inbound.makeAsyncIterator() - - requestLoop: while !Task.isCancelled { - guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { - break requestLoop - } + var iterator = inbound.makeAsyncIterator() - guard - let recoveredIterator = try await self.invokeHandler( - request: httpRequest, - iterator: iterator, - outbound: outbound, - handler: handler - ) - else { - // Handler did not fully consume the request; cannot continue on this - // connection. - break requestLoop - } + requestLoop: while !Task.isCancelled { + guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { + break requestLoop + } - iterator = recoveredIterator + guard + let recoveredIterator = try await self.invokeHandler( + request: httpRequest, + iterator: iterator, + outbound: outbound, + handler: handler, + context: context + ) + else { + // Handler did not fully consume the request; cannot continue on this + // connection. + break requestLoop } + + iterator = recoveredIterator } } catch { self.logger.debug("Error thrown while handling HTTP/1.1 connection", metadata: ["error": "\(error)"]) - try? await channel.channel.close() } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index f3462c9..b96de84 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -133,7 +133,7 @@ extension NIOHTTPServer { @available(anyAppleOS 26.0, *) extension NIOHTTPServer.SocketAddress { - fileprivate init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { + init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { guard let address, let port = address.port else { throw ListeningAddressError.addressOrPortNotAvailable } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift new file mode 100644 index 0000000..dadaab4 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public import HTTPAPIs +public import X509 + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// The request context provided to handlers by ``NIOHTTPServer``. + /// + /// Conforms to: + /// - ``HTTPServerCapability/ConnectionInfo`` — peer / local addresses. + /// - ``HTTPServerCapability/PeerCertificate`` — mTLS-validated peer chain. + /// - ``HTTPServerCapability/CloseableConnection`` — `signalConnectionClose()`. + /// + /// Generic library code can constrain on these capabilities to access + /// per-request data without depending on ``NIOHTTPServer`` directly. + public struct RequestContext: HTTPServerCapability.RequestContext, Sendable { + let connectionContext: ConnectionContext + + init(connectionContext: ConnectionContext) { + self.connectionContext = connectionContext + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.RequestContext: HTTPServerCapability.ConnectionInfo { + /// The peer's address, when known. + /// + /// Returns `nil` if the underlying transport could not report an address + /// or if the address is of an unsupported kind. + public var remoteAddress: NIOHTTPServer.SocketAddress? { + self.connectionContext.remoteAddress + } + + /// The local address the connection is bound to, when known. + /// + /// Returns `nil` under the same conditions as ``remoteAddress``. + public var localAddress: NIOHTTPServer.SocketAddress? { + self.connectionContext.localAddress + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.RequestContext: HTTPServerCapability.PeerCertificate { + /// The peer's mTLS-validated certificate chain, when available. + /// + /// Returns `nil` when mTLS is not configured, or when the configured + /// custom verification callback did not return the derived validated + /// chain. May throw if the chain cannot be retrieved. + public var peerCertificateChain: X509.ValidatedCertificateChain? { + get async throws { + try await self.connectionContext.peerCertificateChain + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer.RequestContext: HTTPServerCapability.CloseableConnection { + /// Signal that the connection should close after the current response. + /// + /// Non-blocking and idempotent. Effective after the current response: + /// + /// - On HTTP/1.1, the response carries `Connection: close` and the + /// channel is closed once the response has been written. + /// - On HTTP/2, the connection sends `GOAWAY`; in-flight streams + /// complete normally before the connection closes. + public func signalConnectionClose() { + self.connectionContext.signalConnectionClose() + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 48ff7b0..bba102b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -30,7 +30,7 @@ import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { typealias NegotiatedChannel = NIONegotiatedHTTPVersion< - NIOAsyncChannel, + HTTP1ChildConnection, (any Channel, NIOHTTP2Handler.AsyncStreamMultiplexer>) > @@ -42,21 +42,13 @@ extension NIOHTTPServer { /// /// - Parameters: /// - serverChannel: The async channel that produces incoming connections. - /// - handler: The request handler. + /// - connectionHandler: The connection handler invoked for each accepted connection. /// /// - Throws: If an error occurs while iterating the incoming connection stream. - func serveSecureUpgrade( + func serveSecureUpgrade( serverChannel: NIOAsyncChannel, Never>, - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + connectionHandler: Handler + ) async throws { try await serverChannel.executeThenClose { inbound in // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child // task would immediately propagate upwards, cancelling all child tasks and bringing down the entire server. @@ -66,29 +58,10 @@ extension NIOHTTPServer { do { for try await upgradeResult in inbound { connectionGroup.addTask { - let negotiatedChannel: NegotiatedChannel - - do { - negotiatedChannel = try await upgradeResult.get() - } catch { - self.logger.debug("Negotiating ALPN failed", metadata: ["error": "\(error)"]) - return - } - - switch negotiatedChannel { - case .http1_1(let requestChannel): - await self.serveHTTP1Connection( - requestChannel: requestChannel, - handler: handler - ) - - case .http2((let connectionChannel, let multiplexer)): - await self.serveHTTP2Connection( - connectionChannel: connectionChannel, - multiplexer: multiplexer, - handler: handler - ) - } + await self.dispatchSecureConnection( + upgradeResult: upgradeResult, + connectionHandler: connectionHandler + ) } } @@ -105,66 +78,123 @@ extension NIOHTTPServer { } } - /// Serves a HTTP/1.1 connection. - /// - /// - Parameters: - /// - requestChannel: The HTTP/1.1 request channel. - /// - handler: The request handler. - private func serveHTTP1Connection( - requestChannel: NIOAsyncChannel, - handler: Handler - ) async - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { - let chainFuture = requestChannel.channel.nioSSL_peerValidatedCertificateChain() + private func dispatchSecureConnection( + upgradeResult: EventLoopFuture, + connectionHandler: Handler + ) async { + let negotiatedChannel: NegotiatedChannel + do { + negotiatedChannel = try await upgradeResult.get() + } catch { + self.logger.debug("Negotiating ALPN failed", metadata: ["error": "\(error)"]) + return + } + + switch negotiatedChannel { + case .http1_1(let child): + // The dispatcher owns the channel's `executeThenClose` so the + // `NIOAsyncWriter` is finished cleanly whether or not the + // connection handler called `handleRequests`. + do { + try await child.asyncChannel.executeThenClose { inbound, outbound in + let chainFuture = child.asyncChannel.channel.nioSSL_peerValidatedCertificateChain() + let context = NIOHTTPServer.makeHTTP1ConnectionContext( + requestChannel: child.asyncChannel, + closeFlag: child.closeFlag, + peerCertificateChainFuture: chainFuture + ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + ) + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } + } + } catch { + self.logger.debug( + "Error tearing down HTTP/1.1 channel", + metadata: ["error": "\(error)"] + ) + } - await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - await self.handleHTTP1RequestChannel( - channel: requestChannel, - handler: handler + case .http2((let connectionChannel, let multiplexer)): + let chainFuture = connectionChannel.nioSSL_peerValidatedCertificateChain() + let context = NIOHTTPServer.makeHTTP2ConnectionContext( + connectionChannel: connectionChannel, + peerCertificateChainFuture: chainFuture ) + let connection = Connection( + server: self, + context: context, + httpProtocol: .http2(connectionChannel: connectionChannel, multiplexer: multiplexer) + ) + do { + try await connectionHandler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug( + "Error thrown by connection handler", + metadata: ["error": "\(error)"] + ) + } } } - /// Serves a HTTP/2 connection by iterating the stream channels and handling each stream concurrently. + /// Builds a ``ConnectionContext`` for an HTTP/2 connection channel. /// - /// - Note: Stream iteration errors are logged but do not propagate to the caller. + /// The context's ``ConnectionContext/signalConnectionClose()`` fires + /// `ChannelShouldQuiesceEvent` on the connection-channel pipeline. NIO's + /// `NIOHTTP2ServerConnectionManagementHandler` (added to that pipeline by + /// `configureAsyncHTTP2Pipeline`) reacts by initiating graceful shutdown — + /// sending `GOAWAY`, letting in-flight streams complete normally, and + /// finally closing the connection. + static func makeHTTP2ConnectionContext( + connectionChannel: any Channel, + peerCertificateChainFuture: EventLoopFuture? + ) -> ConnectionContext { + ConnectionContext( + httpVersion: .http2, + remoteAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.localAddress), + peerCertificateChainFuture: peerCertificateChainFuture, + closeBacking: .http2(connectionChannel: connectionChannel) + ) + } + + /// Drives the request loop on a HTTP/2 connection by iterating the stream + /// channels and handling each stream concurrently. /// - /// - Parameters: - /// - connectionChannel: The underlying NIO channel for the HTTP/2 connection. - /// - multiplexer: The HTTP/2 stream multiplexer. - /// - handler: The request handler. - private func serveHTTP2Connection( + /// This is the per-connection loop body invoked from + /// ``NIOHTTPServer/Connection/handleRequests(handler:)`` for the HTTP/2 + /// case. After iteration ends, this method closes the connection channel. + /// + /// - Note: Stream iteration errors are logged but do not propagate to the caller. + func handleHTTP2Connection( connectionChannel: any Channel, multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer>, - handler: Handler + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { await withDiscardingTaskGroup { streamGroup in do { - let chainFuture = connectionChannel.nioSSL_peerValidatedCertificateChain() - - try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - for try await streamChannel in multiplexer.inbound { - streamGroup.addTask { - await self.handleHTTP2StreamChannel( - channel: streamChannel, - handler: handler - ) - } + for try await streamChannel in multiplexer.inbound { + streamGroup.addTask { + await self.handleHTTP2StreamChannel( + channel: streamChannel, + handler: handler, + context: context + ) } } } catch { @@ -174,13 +204,14 @@ extension NIOHTTPServer { ) } - // The `multiplexer.inbound` iteration exits when our task is cancelled, or when the HTTP/2 stream - // multiplexer finishes or throws. In any case, we are done with this connection here, so tear it down. + // Close the connection channel before the task group joins + // in-flight stream tasks. This drives NIO HTTP/2's + // `propagateChannelInactive`, which closes each stream channel so + // its `handleHTTP2StreamChannel` task can complete cleanly. do { try await connectionChannel.close() } catch ChannelError.alreadyClosed { - // We swallow the error here because the connection channel may already have closed at this point, e.g. - // if the client sent a TCP FIN or a TLS CLOSE_NOTIFY that the event loop processed before we got here. + () } catch { self.logger.error( "Error thrown while closing the HTTP/2 connection channel", @@ -340,17 +371,22 @@ extension NIOHTTPServer { } /// Handles an HTTP/2 stream channel, which carries exactly one request per stream. + /// + /// If the request handler invokes + /// ``ConnectionContext/signalConnectionClose()`` while running, the + /// connection-context's `closeAction` fires `ChannelShouldQuiesceEvent` on + /// the connection-channel pipeline; NIO's HTTP/2 connection management + /// handler reacts by sending `GOAWAY`, letting other in-flight streams + /// complete normally, and finally closing the connection. func handleHTTP2StreamChannel( channel: NIOAsyncChannel, - handler: Handler + handler: Handler, + context: ConnectionContext ) async where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { do { try await channel @@ -366,7 +402,8 @@ extension NIOHTTPServer { request: httpRequest, iterator: iterator, outbound: outbound, - handler: handler + handler: handler, + context: context ) // TODO: handle other state scenarios. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 3f72b69..f031b59 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -63,8 +63,6 @@ import X509 /// ``` @available(anyAppleOS 26.0, *) public struct NIOHTTPServer: HTTPServer { - public struct RequestContext: HTTPServerCapability.RequestContext, Sendable {} - let logger: Logger let configuration: NIOHTTPServerConfiguration @@ -77,11 +75,6 @@ public struct NIOHTTPServer: HTTPServer { var listeningAddressState: NIOLockedValueBox - /// Task-local storage for connection-specific information accessible from request handlers. - /// - /// - SeeAlso: ``ConnectionContext``. - @TaskLocal public static var connectionContext = ConnectionContext() - /// Create a new ``HTTPServer`` implemented over `SwiftNIO`. /// - Parameters: /// - logger: A logger instance for recording server events and debugging information. @@ -132,13 +125,40 @@ public struct NIOHTTPServer: HTTPServer { /// ``` public func serve(handler: Handler) async throws where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { + try await self.serve( + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) + } + + /// Starts an HTTP server with the specified connection handler. + /// + /// This method is the connection-aware counterpart to ``serve(handler:)``. For + /// every accepted TCP/TLS connection (after ALPN negotiation on the secure + /// path), the server materialises a ``Connection`` and a ``ConnectionContext`` + /// and invokes ``NIOHTTPServerConnectionHandler/handleConnection(connection:context:)``. + /// + /// User code that only needs request-level processing should prefer + /// ``serve(handler:)``. Use this entry point when the user code needs to run + /// connection-scoped setup (per-connection logger, metric dimensions, + /// counters), share state between requests on the same connection, or + /// observe state after the connection's request loop returns. + /// + /// ## All-or-nothing listening + /// + /// The server treats its set of listening addresses as a single unit. If an + /// unrecoverable error occurs on any of the listening channels, the server + /// stops listening on **all** remaining addresses and this method returns. + /// + /// - Parameter connectionHandler: An ``NIOHTTPServerConnectionHandler`` + /// implementation that drives the request loop on each accepted + /// connection. + public func serve( + connectionHandler: Handler + ) async throws { // Ensure the listening address promise is always completed on the way out, regardless of whether // binding succeeded, the serve loop returned normally, or an error propagated. defer { self.finishListeningAddressPromise() } @@ -147,7 +167,7 @@ public struct NIOHTTPServer: HTTPServer { return try await withTaskCancellationHandler { try await withGracefulShutdownHandler { - try await self._serve(serverChannels: serverChannels, handler: handler) + try await self._serve(serverChannels: serverChannels, connectionHandler: connectionHandler) } onGracefulShutdown: { self.beginGracefulShutdown(serverChannels: serverChannels) } @@ -157,6 +177,32 @@ public struct NIOHTTPServer: HTTPServer { } } + /// Convenience overload accepting a closure instead of an + /// ``NIOHTTPServerConnectionHandler`` conformance. + /// + /// ```swift + /// try await server.serve { connection, context in + /// var connectionLogger = rootLogger + /// connectionLogger[metadataKey: "peer"] = + /// .string(context.remoteAddress?.host ?? "unknown") + /// connectionLogger.info("connection accepted") + /// defer { connectionLogger.info("connection closed") } + /// + /// try await connection.handleRequests(handler: MyHandler(logger: connectionLogger)) + /// } + /// ``` + public func serve( + connectionHandler: + @Sendable @escaping ( + _ connection: consuming sending NIOHTTPServer.Connection, + _ context: NIOHTTPServer.ConnectionContext + ) async throws -> Void + ) async throws { + try await self.serve( + connectionHandler: NIOHTTPServerClosureConnectionHandler(body: connectionHandler) + ) + } + /// Creates and returns server channels based on the configured transport security. private func makeServerChannels() async throws -> [ServerChannel] { switch self.configuration.transportSecurity.backing { @@ -180,27 +226,25 @@ public struct NIOHTTPServer: HTTPServer { } } - private func _serve( + private func _serve( serverChannels: [ServerChannel], - handler: Handler - ) async throws - where - Handler.RequestContext: ~Copyable, - Handler.RequestContext == RequestContext, - Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable - { + connectionHandler: Handler + ) async throws { try await withThrowingTaskGroup(of: Void.self) { group in for serverChannel in serverChannels { group.addTask { switch serverChannel { case .plaintextHTTP1_1(let http1Channel, _): - try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) + try await self.serveInsecureHTTP1_1( + serverChannel: http1Channel, + connectionHandler: connectionHandler + ) case .secureUpgrade(let secureUpgradeChannel, _): - try await self.serveSecureUpgrade(serverChannel: secureUpgradeChannel, handler: handler) + try await self.serveSecureUpgrade( + serverChannel: secureUpgradeChannel, + connectionHandler: connectionHandler + ) } } } @@ -245,15 +289,13 @@ public struct NIOHTTPServer: HTTPServer { request: HTTPRequest, iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, outbound: NIOAsyncChannelOutboundWriter, - handler: Handler + handler: Handler, + context: ConnectionContext ) async throws -> NIOAsyncChannelInboundStream.AsyncIterator? where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { let readerState = Reader.ReaderState(iterator: iterator) let writerState = ResponseSender.WriterState() @@ -261,7 +303,7 @@ public struct NIOHTTPServer: HTTPServer { do { try await handler.handle( request: request, - requestContext: RequestContext(), + requestContext: RequestContext(connectionContext: context), reader: Reader( readerState: readerState ), diff --git a/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift new file mode 100644 index 0000000..a252cbb --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerClosureConnectionHandler.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A closure-based ``NIOHTTPServerConnectionHandler``. +/// +/// Used internally by ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-((NIOHTTPServer.Connection,NIOHTTPServer.ConnectionContext)->Void)`` +/// to bridge a closure to the protocol-based connection-handler API. +@available(anyAppleOS 26.0, *) +struct NIOHTTPServerClosureConnectionHandler: NIOHTTPServerConnectionHandler { + let body: + @Sendable ( + consuming sending NIOHTTPServer.Connection, + NIOHTTPServer.ConnectionContext + ) async throws -> Void + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + try await self.body(connection, context) + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift new file mode 100644 index 0000000..4b3b935 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerConnectionHandler.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A protocol for handling the lifecycle of a single ``NIOHTTPServer`` connection. +/// +/// Conforming types receive each new connection as it arrives, run any +/// connection-scoped setup (loggers, metric dimensions, atomic counters), and +/// drive the request loop via +/// ``NIOHTTPServer/NIOHTTPServer/Connection/handleRequests(handler:)``. State that must +/// outlive ``handleConnection(connection:context:)`` should live in reference +/// types (classes, actors, atomics) the conformer captures or closes over — +/// the `connection` argument is consumed. +/// +/// User code typically uses ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-(Handler)`` +/// (the protocol-based form) or ``NIOHTTPServer/NIOHTTPServer/serve(connectionHandler:)-((NIOHTTPServer.Connection,NIOHTTPServer.ConnectionContext)->Void)`` +/// (the closure-based form). If only request-level work is needed, prefer +/// ``NIOHTTPServer/NIOHTTPServer/serve(handler:)``, which uses a built-in default connection handler. +@available(anyAppleOS 26.0, *) +public protocol NIOHTTPServerConnectionHandler: Sendable { + /// Handle a single connection. + /// + /// - Parameters: + /// - connection: The active connection. The handler is expected to drive + /// the request loop by calling ``NIOHTTPServer/NIOHTTPServer/Connection/handleRequests(handler:)`` + /// on it. If `handleConnection` returns without calling + /// `handleRequests`, the connection is dropped on scope exit, which + /// closes the underlying channel. + /// - context: Connection-scoped data. Borrowed for the duration of the + /// call. + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift new file mode 100644 index 0000000..5b6a5f1 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public import HTTPAPIs + +/// A connection handler that forwards every request on the connection to a single +/// ``HTTPServerRequestHandler`` and performs no connection-scoped work. +/// +/// This is the default connection handler used by ``NIOHTTPServer/serve(handler:)``. +/// User code may also instantiate it directly when it wants to pass a request +/// handler through a connection-handler API without doing per-connection work. +@available(anyAppleOS 26.0, *) +public struct NIOHTTPServerDefaultConnectionHandler: + NIOHTTPServerConnectionHandler +where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender +{ + /// The request handler invoked for every request on the connection. + public let handler: Handler + + /// Create a default connection handler that forwards every request on the + /// connection to `handler`. + public init(handler: Handler) { + self.handler = handler + } + + public func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + try await connection.handleRequests(handler: self.handler) + } +} diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift index ae65104..e5c6a2e 100644 --- a/Sources/NIOHTTPServer/ServerChannel.swift +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -22,7 +22,7 @@ extension NIOHTTPServer { /// Upgrade. enum ServerChannel { case plaintextHTTP1_1( - channel: NIOAsyncChannel, Never>, + channel: NIOAsyncChannel, quiescingHelper: ServerQuiescingHelper ) diff --git a/Sources/Example/Example.swift b/Sources/RequestHandlerExample/RequestHandlerExample.swift similarity index 78% rename from Sources/Example/Example.swift rename to Sources/RequestHandlerExample/RequestHandlerExample.swift index d917a09..2e19b6f 100644 --- a/Sources/Example/Example.swift +++ b/Sources/RequestHandlerExample/RequestHandlerExample.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift HTTP Server open source project // -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -14,6 +14,7 @@ import BasicContainers import Crypto +import ExampleSupport import Foundation import Instrumentation import Logging @@ -22,7 +23,7 @@ import X509 @main @available(anyAppleOS 26.0, *) -struct Example { +struct RequestHandlerExample { static func main() async throws { try await serve() } @@ -30,8 +31,11 @@ struct Example { @concurrent static func serve() async throws { InstrumentationSystem.bootstrap(LogTracer()) - var logger = Logger(label: "Logger") - logger.logLevel = .trace + let logger: Logger = { + var logger = Logger(label: "RequestHandlerExample") + logger.logLevel = .trace + return logger + }() let privateKey = P256.Signing.PrivateKey() let server = NIOHTTPServer( @@ -61,7 +65,13 @@ struct Example { ) ) + // A single request handler is invoked for every request, regardless of + // which connection it arrived on. Per-request connection-scoped data + // (peer address, mTLS chain) is accessed via the request context's + // capability conformances. try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in + let peer = requestContext.remoteAddress.map { "\($0)" } ?? "unknown" + logger.info("request from \(peer)") var body = UniqueArray(copying: "Well, hello!".utf8) try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) } diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift new file mode 100644 index 0000000..9d0c34a --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -0,0 +1,955 @@ +//===----------------------------------------------------------------------===// +// +// 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 BasicContainers +import Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import Testing + +@testable import NIOHTTPServer + +/// State observed by the connection handlers used in this test suite. The state lives +/// in a class so it survives `consuming` calls to `handleConnection` and can be +/// inspected from the test body. +@available(anyAppleOS 26.0, *) +final class ConnectionLifecycleTestState: Sendable { + let connectionInvocations = NIOLockedValueBox(0) + let requestInvocations = NIOLockedValueBox(0) + let observedRemoteAddresses = NIOLockedValueBox<[NIOHTTPServer.SocketAddress?]>([]) + let observedLocalAddresses = NIOLockedValueBox<[NIOHTTPServer.SocketAddress?]>([]) + let observedFinalCounter = NIOLockedValueBox(nil) +} + +/// A connection handler that counts invocations and runs a single request handler +/// for every request on the connection. +@available(anyAppleOS 26.0, *) +struct CountingConnectionHandler: NIOHTTPServerConnectionHandler +where + Handler.RequestContext == NIOHTTPServer.RequestContext, + Handler.Reader == NIOHTTPServer.Reader, + Handler.ResponseSender == NIOHTTPServer.ResponseSender +{ + let state: ConnectionLifecycleTestState + let requestHandler: Handler + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.state.connectionInvocations.withLockedValue { $0 += 1 } + self.state.observedRemoteAddresses.withLockedValue { $0.append(context.remoteAddress) } + self.state.observedLocalAddresses.withLockedValue { $0.append(context.localAddress) } + + let connectionLocalCounter = NIOLockedValueBox(0) + let countingHandler = ConnectionScopedRequestHandler( + wrappedHandler: self.requestHandler, + globalCounter: self.state.requestInvocations, + localCounter: connectionLocalCounter + ) + try await connection.handleRequests(handler: countingHandler) + + self.state.observedFinalCounter.withLockedValue { $0 = connectionLocalCounter.withLockedValue { $0 } } + } +} + +/// A request handler that increments two counters before delegating to the wrapped handler. +@available(anyAppleOS 26.0, *) +struct ConnectionScopedRequestHandler: HTTPServerRequestHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + typealias RequestContext = NIOHTTPServer.RequestContext + typealias Reader = NIOHTTPServer.Reader + typealias ResponseSender = NIOHTTPServer.ResponseSender + + let wrappedHandler: Wrapped + let globalCounter: NIOLockedValueBox + let localCounter: NIOLockedValueBox + + func handle( + request: HTTPRequest, + requestContext: consuming NIOHTTPServer.RequestContext, + reader: consuming sending NIOHTTPServer.Reader, + responseSender: consuming sending NIOHTTPServer.ResponseSender + ) async throws { + self.globalCounter.withLockedValue { $0 += 1 } + self.localCounter.withLockedValue { $0 += 1 } + try await self.wrappedHandler.handle( + request: request, + requestContext: requestContext, + reader: reader, + responseSender: responseSender + ) + } +} + +@Suite +struct ConnectionLifecycleTests { + @available(anyAppleOS 26.0, *) + static var serverLogger: Logger { + var logger = Logger(label: "ConnectionLifecycleTests") + logger.logLevel = .info + return logger + } + + /// Starts `server` with `connectionHandler`, waits for it to begin listening, runs `body` + /// with the listening address, then cancels the server task. + @available(anyAppleOS 26.0, *) + static func withServer( + server: NIOHTTPServer, + connectionHandler: Handler, + body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve(connectionHandler: connectionHandler) + } + + let listeningAddresses = try await server.listeningAddresses + let address = try #require(listeningAddresses.first) + + try await body(address) + + group.cancelAll() + } + } + + /// Helper that echoes the request body back as a 200 OK response. Fully + /// drains the request body, which is required for the per-channel loop + /// to recover the iterator and keep the HTTP/1.1 connection alive across + /// requests. + @available(anyAppleOS 26.0, *) + static func echoHandler() -> HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + > { + HTTPServerClosureRequestHandler { request, requestContext, reader, responseSender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: responseSender) + } + } + + /// HTTP/1.1: connecting twice results in two `handleConnection` invocations, + /// each with non-nil `remoteAddress` and `localAddress`. + @available(anyAppleOS 26.0, *) + @Test("serve(connectionHandler:) — per-connection invocation count (HTTP/1.1)") + func testPerConnectionInvocationHTTP1_1() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let state = ConnectionLifecycleTestState() + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: Self.echoHandler()) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + for _ in 1...2 { + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 2) + let remotes = state.observedRemoteAddresses.withLockedValue { $0 } + let locals = state.observedLocalAddresses.withLockedValue { $0 } + #expect(remotes.count == 2) + #expect(locals.count == 2) + for remote in remotes { #expect(remote != nil) } + for local in locals { #expect(local != nil) } + } + + /// HTTP/1.1 keep-alive: two requests on the same connection result in a + /// single `handleConnection` invocation that runs the request handler twice. + @available(anyAppleOS 26.0, *) + @Test("HTTP/1.1 keep-alive — single connection-handler invocation, multiple requests") + func testKeepAliveSingleInvocationMultipleRequests() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let state = ConnectionLifecycleTestState() + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: Self.echoHandler()) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + // Pipeline both requests up-front, then read both responses. + for path in ["/a", "/b"] { + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: path))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + } + + var iterator = inbound.makeAsyncIterator() + for _ in 0..<2 { + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 1) + #expect(state.requestInvocations.withLockedValue { $0 } == 2) + } + + /// HTTP/2: three concurrent streams on one connection result in one + /// `handleConnection` call and three request-handler calls. A user counter + /// held by the connection handler observes three after `handleRequests` + /// returns. + @available(anyAppleOS 26.0, *) + @Test("HTTP/2 — single connection-handler invocation, concurrent streams") + func testHTTP2SingleInvocationConcurrentStreams() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + let numStreams = 3 + let allRequestsReceived = elg.any().makePromise(of: Void.self) + let arrivedCounter = NIOLockedValueBox(0) + + let state = ConnectionLifecycleTestState() + + // Inner request handler that synchronises three concurrent streams via the + // promise so they all execute before any responds. + let synchronizingRequestHandler: + HTTPServerClosureRequestHandler< + NIOHTTPServer.RequestContext, + NIOHTTPServer.Reader, + NIOHTTPServer.ResponseSender + > = HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + let arrived = arrivedCounter.withLockedValue { value -> Int in + value += 1 + return value + } + if arrived == numStreams { + allRequestsReceived.succeed() + } else { + try await allRequestsReceived.futureResult.get() + } + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) + } + let connectionHandler = CountingConnectionHandler(state: state, requestHandler: synchronizingRequestHandler) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + try await withThrowingTaskGroup { group in + for _ in 1...numStreams { + group.addTask { + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write( + .head(.init(method: .get, scheme: "https", authority: "", path: "/")) + ) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + } + try await group.waitForAll() + } + } + + #expect(state.connectionInvocations.withLockedValue { $0 } == 1) + // handleConnection returns after the multiplexer's inbound iteration ends, + // which happens after the server task is cancelled at the end of `withServer`. + for _ in 0..<50 { + if state.observedFinalCounter.withLockedValue({ $0 }) != nil { break } + try await Task.sleep(for: .milliseconds(100)) + } + #expect(state.observedFinalCounter.withLockedValue { $0 } == numStreams) + } + + /// HTTP/1.1: a request handler that signals close has its response carry + /// `Connection: close`, and a follow-up read on the same socket returns nil. + @available(anyAppleOS 26.0, *) + @Test("signalConnectionClose() on HTTP/1.1") + func testSignalConnectionCloseHTTP1_1() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + + let connectionHandler = NIOHTTPServerDefaultConnectionHandler( + handler: HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + requestContext.signalConnectionClose() + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .unauthorized), buffer: &buffer) + } + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + + var iterator = inbound.makeAsyncIterator() + let head = try await iterator.next() + guard case .head(let response) = head else { + Issue.record("Expected response head but got \(String(describing: head))") + return + } + #expect(response.status == .unauthorized) + #expect(response.headerFields[.connection] == "close") + + while let part = try await iterator.next() { + if case .end = part { break } + } + + let afterEnd = try await iterator.next() + #expect(afterEnd == nil) + } + } + } + + /// HTTP/2: a request handler that signals close causes the connection to + /// stop accepting new streams. In-flight streams complete normally before + /// the connection is torn down. + @available(anyAppleOS 26.0, *) + @Test("signalConnectionClose() on HTTP/2") + func testSignalConnectionCloseHTTP2() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + let numInflight = 3 + let allRequestsReceived = elg.any().makePromise(of: Void.self) + let arrivedCounter = NIOLockedValueBox(0) + + let connectionHandler = NIOHTTPServerDefaultConnectionHandler( + handler: HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + let arrived = arrivedCounter.withLockedValue { value -> Int in + value += 1 + return value + } + if arrived == numInflight { + requestContext.signalConnectionClose() + allRequestsReceived.succeed() + } else { + try await allRequestsReceived.futureResult.get() + } + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) + } + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + try await withThrowingTaskGroup { group in + for _ in 1...numInflight { + group.addTask { + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write( + .head(.init(method: .get, scheme: "https", authority: "", path: "/")) + ) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var sawHead = false + while let part = try await iterator.next() { + if case .head(let response) = part { + #expect(response.status == .ok) + sawHead = true + } + if case .end = part { break } + } + #expect(sawHead, "All in-flight streams should complete normally with status ok") + } + } + } + try await group.waitForAll() + } + } + } + + /// HTTP/1.1: calling `signalConnectionClose()` twice within a single request + /// handler is harmless — the response still carries a single + /// `Connection: close` header and the channel is closed exactly once. + @available(anyAppleOS 26.0, *) + @Test("signalConnectionClose() is idempotent (HTTP/1.1)") + func testSignalConnectionCloseIdempotent() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + + let connectionHandler = NIOHTTPServerDefaultConnectionHandler( + handler: HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + requestContext.signalConnectionClose() + requestContext.signalConnectionClose() + requestContext.signalConnectionClose() + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) + } + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + + var iterator = inbound.makeAsyncIterator() + let head = try await iterator.next() + guard case .head(let response) = head else { + Issue.record("Expected response head but got \(String(describing: head))") + return + } + #expect(response.headerFields[.connection] == "close") + // Look for any duplicate `Connection` headers; the field should + // appear exactly once with value "close". + let connectionValues = response.headerFields[values: .connection] + #expect(connectionValues == ["close"]) + + while let part = try await iterator.next() { + if case .end = part { break } + } + let _trailing = try await iterator.next() + #expect(_trailing == nil) + } + } + } + + /// HTTP/1.1: calling `signalConnectionClose()` AFTER the response head has + /// already been written. The channel still closes after the response `.end`, + /// but the head can't be amended — it's already on the wire. The client + /// observes the response without a `Connection: close` header followed by + /// EOF. + @available(anyAppleOS 26.0, *) + @Test("signalConnectionClose() after response head is written (HTTP/1.1)") + func testSignalConnectionCloseAfterHead() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + + let connectionHandler = NIOHTTPServerDefaultConnectionHandler( + handler: HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + let writer = try await responseSender.send(.init(status: .ok)) + requestContext.signalConnectionClose() + var buffer = UniqueArray(copying: [0x21]) + try await writer.finish(buffer: &buffer, finalElement: nil) + } + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + + var iterator = inbound.makeAsyncIterator() + let head = try await iterator.next() + guard case .head(let response) = head else { + Issue.record("Expected response head but got \(String(describing: head))") + return + } + #expect(response.status == .ok) + // We deliberately don't assert anything about the `Connection` + // header here: `responseSender.send(_:)` returns once the head + // is yielded to the async-stream queue, which may be before the + // keep-alive handler has actually processed it on the event + // loop. So the flag set immediately after `send` is sometimes + // still observed in time to amend the head. The reliable + // observable is that the channel closes after the response. + + while let part = try await iterator.next() { + if case .end = part { break } + } + let _trailing = try await iterator.next() + #expect(_trailing == nil) + } + } + } + + /// HTTP/2: after a stream signals close, the connection drains existing + /// streams and rejects new stream creation. Opening a stream against the + /// already-quiesced connection fails (NIOHTTP2's GOAWAY mechanics). + @available(anyAppleOS 26.0, *) + @Test("HTTP/2 rejects new streams after signalConnectionClose()") + func testHTTP2RejectsNewStreamsAfterClose() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + + let connectionHandler = NIOHTTPServerDefaultConnectionHandler( + handler: HTTPServerClosureRequestHandler { + request, + requestContext, + reader, + responseSender in + requestContext.signalConnectionClose() + var buffer = UniqueArray(copying: []) + try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) + } + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + // First stream completes normally and signals connection close. + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "https", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + + // After the close signal has been observed by the connection-management + // handler, opening a new stream should fail. We poll for a short + // window since the GOAWAY processing is asynchronous to the response + // we just observed. + var rejected = false + for _ in 0..<50 { + do { + _ = try await streamManager.openStream() + } catch { + rejected = true + break + } + try await Task.sleep(for: .milliseconds(50)) + } + #expect(rejected, "Expected new-stream creation to fail after GOAWAY.") + } + } + + /// A throwing connection handler is logged at debug level by the server but + /// doesn't bring it down: a subsequent connection on the same server is + /// served normally. + @available(anyAppleOS 26.0, *) + @Test("Throwing connection handler doesn't kill the server") + func testThrowingConnectionHandlerDoesNotKillServer() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = ThrowingFirstConnectionHandler( + connectionInvocations: connectionInvocations + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + // First connection: the handler throws after consuming the connection; + // we expect the channel to close without a response. + let firstClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await firstClient.executeThenClose { inbound, _ in + var iterator = inbound.makeAsyncIterator() + let part = try await iterator.next() + #expect(part == nil) + } + + // Second connection: the handler runs the request loop normally. + let secondClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await secondClient.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var sawOK = false + while let part = try await iterator.next() { + if case .head(let response) = part { + #expect(response.status == .ok) + sawOK = true + } + if case .end = part { break } + } + #expect(sawOK, "Second connection should have been served normally.") + } + } + + #expect(connectionInvocations.withLockedValue { $0 } == 2) + } + + /// A connection handler that returns without calling `handleRequests` + /// effectively drops the connection: the channel closes immediately and + /// the client sees EOF without any response. + @available(anyAppleOS 26.0, *) + @Test("Connection handler returning without handleRequests drops the connection") + func testConnectionHandlerEarlyReturn() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = NoOpConnectionHandler(connectionInvocations: connectionInvocations) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + // The server side dropped the connection immediately; trying to + // read either returns nil (clean EOF) or throws (peer reset). + // Either is valid evidence that the connection was dropped. + try? await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) + try? await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + var receivedAnyResponsePart = false + do { + while let part = try await iterator.next() { + if case .head = part { receivedAnyResponsePart = true } + } + } catch { + // Connection-reset / read errors are also valid evidence that + // the connection was dropped. + } + #expect(!receivedAnyResponsePart, "Expected no response head from a dropped connection.") + } + } + + #expect(connectionInvocations.withLockedValue { $0 } == 1) + } + + /// `ConnectionContext.httpVersion` reflects the protocol negotiated for the + /// connection. Verified for plaintext HTTP/1.1, secure-upgrade-negotiated + /// HTTP/1.1, and secure-upgrade-negotiated HTTP/2. + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for plaintext HTTP/1.1") + func testHTTPVersionPlaintextHTTP1_1() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http1_1) + } + + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/1.1") + func testHTTPVersionSecureUpgradeHTTP1_1() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http1_1.alpnIdentifier + ) + guard case .http1(let http1Channel) = clientChannel else { + Issue.record("Expected HTTP/1.1 negotiation, got \(clientChannel).") + return + } + try await http1Channel.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http1_1) + } + + @available(anyAppleOS 26.0, *) + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/2") + func testHTTPVersionSecureUpgradeHTTP2() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let observed = NIOLockedValueBox(nil) + + let connectionHandler = HTTPVersionRecordingConnectionHandler( + observed: observed, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 negotiation, got \(clientChannel).") + return + } + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "https", authority: "", path: "/"))) + try await outbound.write(.body(ByteBuffer(string: "x"))) + try await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try await iterator.next() { + if case .end = part { break } + } + } + } + + #expect(observed.withLockedValue { $0 } == .http2) + } + + /// Multiple HTTP/1.1 keep-alive connections in parallel each receive their + /// own `handleConnection` invocation and connection-scoped state isn't + /// shared between them — each connection's per-request counter only + /// reflects its own requests. + @available(anyAppleOS 26.0, *) + @Test("State isolation across keep-alive HTTP/1.1 connections") + func testKeepAliveStateIsolationAcrossConnections() async throws { + let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) + let perConnectionCounters = NIOLockedValueBox<[Int]>([]) + + let connectionHandler = PerConnectionCounterHandler( + perConnectionCounters: perConnectionCounters, + wrappedHandler: Self.echoHandler() + ) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + await withThrowingTaskGroup(of: Void.self) { group in + // Connection A makes 3 requests; connection B makes 1. + for requestCount in [3, 1] { + group.addTask { + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: serverAddress) + try await client.executeThenClose { inbound, outbound in + for i in 0.. + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + let invocation = self.connectionInvocations.withLockedValue { value -> Int in + value += 1 + return value + } + if invocation == 1 { + throw TestError.intentional + } + try await connection.handleRequests { request, _, reader, sender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + } + } +} + +/// Connection handler that records the invocation count and returns without +/// calling `handleRequests`, dropping the connection. +@available(anyAppleOS 26.0, *) +struct NoOpConnectionHandler: NIOHTTPServerConnectionHandler { + let connectionInvocations: NIOLockedValueBox + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.connectionInvocations.withLockedValue { $0 += 1 } + // Intentionally do not call `handleRequests` — the connection is + // dropped and the channel is closed on scope exit. + } +} + +/// Connection handler that records the negotiated HTTP version on its first +/// invocation and forwards every request to a wrapped handler. +@available(anyAppleOS 26.0, *) +struct HTTPVersionRecordingConnectionHandler: + NIOHTTPServerConnectionHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + let observed: NIOLockedValueBox + let wrappedHandler: Wrapped + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + self.observed.withLockedValue { value in + if value == nil { value = context.httpVersion } + } + try await connection.handleRequests(handler: self.wrappedHandler) + } +} + +/// Connection handler that counts requests on its own connection and appends +/// the final count to a shared array when the connection ends. +@available(anyAppleOS 26.0, *) +struct PerConnectionCounterHandler: NIOHTTPServerConnectionHandler +where + Wrapped.RequestContext == NIOHTTPServer.RequestContext, + Wrapped.Reader == NIOHTTPServer.Reader, + Wrapped.ResponseSender == NIOHTTPServer.ResponseSender +{ + let perConnectionCounters: NIOLockedValueBox<[Int]> + let wrappedHandler: Wrapped + + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + let counter = NIOLockedValueBox(0) + let countingHandler = ConnectionScopedRequestHandler( + wrappedHandler: self.wrappedHandler, + globalCounter: NIOLockedValueBox(0), + localCounter: counter + ) + try await connection.handleRequests(handler: countingHandler) + let finalCount = counter.withLockedValue { $0 } + self.perConnectionCounters.withLockedValue { $0.append(finalCount) } + } +} + +/// Connection handler that uses the closure-overload of +/// ``NIOHTTPServer/Connection/handleRequests(handler:)-((@Sendable)``. +@available(anyAppleOS 26.0, *) +struct ClosureRequestHandlerConnectionHandler: NIOHTTPServerConnectionHandler { + func handleConnection( + connection: consuming sending NIOHTTPServer.Connection, + context: NIOHTTPServer.ConnectionContext + ) async throws { + try await connection.handleRequests { request, _, reader, sender in + try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) + } + } +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 2f01ff5..e9479c4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -143,7 +143,7 @@ struct NIOHTTPServerTests { serverHandler: HTTPServerClosureRequestHandler { request, requestContext, reader, responseWriter in #expect(request == Self.makeRequest(method: .post, for: httpVersion)) - let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) + let peerChain = try #require(try await requestContext.peerCertificateChain) #expect(Array(peerChain) == [clientChain.leaf]) let (buffer, finalElement) = try await reader.collect(upTo: Self.bodyData.readableBytes + 1) { diff --git a/Tests/NIOHTTPServerTests/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index 0874e0e..fe19b49 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -16,4 +16,5 @@ enum TestError: Error { case errorWhileReading case errorWhileWriting + case intentional } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index 4621d12..a6a03ab 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -27,16 +27,13 @@ extension NIOHTTPServer { handler: Handler ) async throws where - Handler.RequestContext: ~Copyable, Handler.RequestContext == RequestContext, Handler.Reader == Reader, - Handler.Reader: ~Copyable, - Handler.ResponseSender == ResponseSender, - Handler.ResponseSender: ~Copyable + Handler.ResponseSender == ResponseSender { // The server requires a NIOAsyncChannel, so we create one from the test channel let serverTestAsyncChannel = try await testChannel.eventLoop.submit { - try NIOAsyncChannel, Never>( + try NIOAsyncChannel( wrappingChannelSynchronously: testChannel, configuration: .init() ) @@ -48,6 +45,9 @@ extension NIOHTTPServer { try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) _ = try await self.listeningAddresses - try await self.serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) + try await self.serveInsecureHTTP1_1( + serverChannel: serverTestAsyncChannel, + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift index c51871d..7581294 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift @@ -48,6 +48,9 @@ extension NIOHTTPServer { try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) _ = try await self.listeningAddresses - try await self.serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) + try await self.serveSecureUpgrade( + serverChannel: testAsyncChannel, + connectionHandler: NIOHTTPServerDefaultConnectionHandler(handler: handler) + ) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift index 12aadb5..d6a8013 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift @@ -88,7 +88,7 @@ struct TestingChannelHTTP1Server { isSecure: false ).get() - // Write the connection channel to the server channel to simulate an incoming connection + // Write the child connection to the server channel to simulate an incoming connection try await self.serverTestChannel.writeInbound(serverAsyncConnectionChannel) let clientTestingChannel = try await NIOAsyncTestingChannel.createActiveChannel() From 413211a78ee6c0a86e7dd945bc12ba0793c5dfa4 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 30 Jun 2026 12:00:43 +0100 Subject: [PATCH 2/8] Disable flaky test --- Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift index 9d0c34a..8d23ee1 100644 --- a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -517,8 +517,10 @@ struct ConnectionLifecycleTests { /// HTTP/2: after a stream signals close, the connection drains existing /// streams and rejects new stream creation. Opening a stream against the /// already-quiesced connection fails (NIOHTTP2's GOAWAY mechanics). + /// TODO: this test is disabled since it's currently flaky; because of a bug in NIO where inbound NIOAsyncChannels are + /// dropped if not consumed, and a precondition fails because they've not been finished. @available(anyAppleOS 26.0, *) - @Test("HTTP/2 rejects new streams after signalConnectionClose()") + @Test("HTTP/2 rejects new streams after signalConnectionClose()", .disabled()) func testHTTP2RejectsNewStreamsAfterClose() async throws { let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup @@ -580,7 +582,7 @@ struct ConnectionLifecycleTests { /// doesn't bring it down: a subsequent connection on the same server is /// served normally. @available(anyAppleOS 26.0, *) - @Test("Throwing connection handler doesn't kill the server") + @Test("Throwing connection handler doesn't bring down the server") func testThrowingConnectionHandlerDoesNotKillServer() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let connectionInvocations = NIOLockedValueBox(0) From f4c7dbe61e9131a96306dd9f91396d3b246e1cd4 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 30 Jun 2026 14:32:15 +0100 Subject: [PATCH 3/8] Format --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index eec3f26..46a134a 100644 --- a/Package.swift +++ b/Package.swift @@ -66,7 +66,7 @@ let package = Package( .target( name: "ExampleSupport", dependencies: [ - .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "Tracing", package: "swift-distributed-tracing") ], swiftSettings: extraSettings ), From 74442f73ad2f61527cdaf46a56a6c30b4f0863d4 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 3 Jul 2026 14:46:10 +0100 Subject: [PATCH 4/8] Use new TaskLocal logging APIs --- Package.swift | 2 +- .../ConnectionHandlerExample.swift | 100 ++++++++++-------- .../NIOHTTPServer+ConnectionContext.swift | 6 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 4 +- .../NIOHTTPServerResponseSender.swift | 4 +- .../RequestHandlerExample.swift | 84 ++++++++------- 6 files changed, 107 insertions(+), 93 deletions(-) diff --git a/Package.swift b/Package.swift index 46a134a..ac45091 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,7 @@ let package = Package( ), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.4.1"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.19.1"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.13.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.14.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.37.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.34.1"), diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift index 3460eb1..6de49f6 100644 --- a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -31,58 +31,66 @@ struct ConnectionHandlerExample { @concurrent static func serve() async throws { InstrumentationSystem.bootstrap(LogTracer()) - let rootLogger: Logger = { - var logger = Logger(label: "ConnectionHandlerExample") - logger.logLevel = .trace - return logger - }() - let privateKey = P256.Signing.PrivateKey() - let server = NIOHTTPServer( - logger: rootLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 12346), - supportedHTTPVersions: [.http1_1, .http2(config: .init())], - transportSecurity: .tls( - credentials: .inMemory( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) + var rootLogger = Logger(label: "ConnectionHandlerExample") + rootLogger.logLevel = .trace + try await withLogger(rootLogger) { rootLogger in + let privateKey = P256.Signing.PrivateKey() + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12346), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) ) ) ) - ) - // A connection handler runs once per accepted connection and drives the - // request loop via `connection.handleRequests`. Both the connection - // handler and the request handler are inline closures. - try await server.serve { connection, context in - let connectionLogger: Logger = { - var logger = rootLogger - let peer = context.remoteAddress.map { "\($0)" } ?? "unknown" - logger[metadataKey: "peer"] = .string(peer) - logger[metadataKey: "http"] = .string("\(context.httpVersion)") - return logger - }() - connectionLogger.info("connection accepted") - defer { connectionLogger.info("connection closed") } + // A connection handler runs once per accepted connection and drives the + // request loop via `connection.handleRequests`. Both the connection + // handler and the request handler are inline closures. + try await server.serve { connection, context in + var connection = Optional(connection) + try await withLogger(mergingMetadata: [ + "peer": .string(context.remoteAddress.map { "\($0)" } ?? "unknown"), + "http": .string(context.httpVersion.rawValue) + ]) { connectionLogger in + connectionLogger.info("connection accepted") + defer { connectionLogger.info("connection closed") } + + try await connection.take()!.handleRequests { request, _, _, responseSender in + var responseSender = Optional(responseSender) + try await withLogger(mergingMetadata: [ + "path": .string(request.path ?? "") + ]) { requestLogger in + requestLogger.info("request received") + defer { requestLogger.info("request completed") } - try await connection.handleRequests { request, _, _, responseSender in - connectionLogger.info("request received: \(request.path ?? "")") - var body = UniqueArray(copying: "Well, hello!".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) + var body = UniqueArray(copying: "Well, hello!".utf8) + try await responseSender.take()!.sendAndFinish( + HTTPResponse(status: .ok), + buffer: &body + ) + } + } + } } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 6a9123a..2f0d214 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -20,9 +20,9 @@ public import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { /// The application-level HTTP version negotiated for a connection. - public enum HTTPVersion: Sendable, Hashable { - case http1_1 - case http2 + public enum HTTPVersion: String, Sendable, Hashable { + case http1_1 = "http/1.1" + case http2 = "http/2" } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index dc687b1..5de87a3 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -194,8 +194,8 @@ public struct NIOHTTPServer: HTTPServer { public func serve( connectionHandler: @Sendable @escaping ( - _ connection: consuming sending NIOHTTPServer.Connection, - _ context: NIOHTTPServer.ConnectionContext + _ connection: consuming sending Connection, + _ context: ConnectionContext ) async throws -> Void ) async throws { try await self.serve( diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift index 2e7d898..a767fcf 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -73,7 +73,7 @@ extension NIOHTTPServer.ResponseSender { if span.isEmpty { done = true } else { - byteBuffer.writeBytes(span.span.bytes) + unsafe byteBuffer.writeBytes(span.span.bytes) } } @@ -98,7 +98,7 @@ extension NIOHTTPServer.ResponseSender { if span.isEmpty { done = true } else { - byteBuffer.writeBytes(span.span.bytes) + unsafe byteBuffer.writeBytes(span.span.bytes) } } diff --git a/Sources/RequestHandlerExample/RequestHandlerExample.swift b/Sources/RequestHandlerExample/RequestHandlerExample.swift index 2e19b6f..9da11d0 100644 --- a/Sources/RequestHandlerExample/RequestHandlerExample.swift +++ b/Sources/RequestHandlerExample/RequestHandlerExample.swift @@ -31,49 +31,55 @@ struct RequestHandlerExample { @concurrent static func serve() async throws { InstrumentationSystem.bootstrap(LogTracer()) - let logger: Logger = { - var logger = Logger(label: "RequestHandlerExample") - logger.logLevel = .trace - return logger - }() - - let privateKey = P256.Signing.PrivateKey() - let server = NIOHTTPServer( - logger: logger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), - supportedHTTPVersions: [.http1_1, .http2(config: .init())], - transportSecurity: .tls( - credentials: .inMemory( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) + var rootLogger = Logger(label: "RequestHandlerExample") + rootLogger.logLevel = .trace + try await withLogger(rootLogger) { rootLogger in + let privateKey = P256.Signing.PrivateKey() + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) ) ) ) - ) - // A single request handler is invoked for every request, regardless of - // which connection it arrived on. Per-request connection-scoped data - // (peer address, mTLS chain) is accessed via the request context's - // capability conformances. - try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in - let peer = requestContext.remoteAddress.map { "\($0)" } ?? "unknown" - logger.info("request from \(peer)") - var body = UniqueArray(copying: "Well, hello!".utf8) - try await responseSender.sendAndFinish(HTTPResponse(status: .ok), buffer: &body) + // A single request handler is invoked for every request, regardless of + // which connection it arrived on. Per-request connection-scoped data + // (peer address, mTLS chain) is accessed via the request context's + // capability conformances. + try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in + var responseSender = Optional(responseSender) + let peer = requestContext.remoteAddress.map { "\($0)" } ?? "unknown" + try await withLogger(mergingMetadata: ["peer": .string(peer)]) { requestLogger in + requestLogger.info("request started") + defer { requestLogger.info("request completed") } + + var body = UniqueArray(copying: "Well, hello!".utf8) + try await responseSender.take()!.sendAndFinish( + HTTPResponse(status: .ok), + buffer: &body + ) + } + } } } } From 94995d8e2451d6f6273600cad5a708802283c3ee Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 3 Jul 2026 17:46:55 +0100 Subject: [PATCH 5/8] Split out CloseableConnection into follow-up PR --- .../NIOHTTPServer/HTTPKeepAliveHandler.swift | 71 ++--- ...verCapability+ConnectionCapabilities.swift | 17 -- .../NIOHTTPServer+Connection.swift | 4 +- .../NIOHTTPServer+ConnectionContext.swift | 43 +-- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 58 +--- .../NIOHTTPServer+RequestContext.swift | 16 - .../NIOHTTPServer+SecureUpgrade.swift | 28 +- Sources/NIOHTTPServer/ServerChannel.swift | 2 +- .../ConnectionLifecycleTests.swift | 285 ------------------ .../NIOHTTPServer+HTTP1.swift | 2 +- 10 files changed, 48 insertions(+), 478 deletions(-) diff --git a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift index 7d0a970..26f7cb6 100644 --- a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift +++ b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift @@ -12,15 +12,11 @@ // //===----------------------------------------------------------------------===// -import NIOConcurrencyHelpers import NIOCore import NIOHTTPTypes -/// A NIO channel handler that ensures HTTP/1.1 keep-alive semantics are honored -/// when the server starts writing a response before the request body has been -/// fully read, and that reacts to the connection-scoped close flag by amending -/// the next response head with `Connection: close` and closing the channel after -/// the response is written. +/// A NIO channel handler that ensures HTTP/1.1 keep-alive semantics are honored when +/// the server starts writing a response before the request body has been fully read. /// /// The handler buffers final response parts (head + any body fragments + end) when /// they are written before the request `.end` has been received. The buffer is @@ -30,15 +26,13 @@ import NIOHTTPTypes /// - **`flush`**: an upstream writer (e.g. `NIOAsyncChannelOutboundWriter`) forced a /// flush. /// -/// At each deadline, if request `.end` has arrived and no close has been -/// signalled, the buffer is flushed as-is and the connection is reusable. If -/// request `.end` has *not* arrived, or the shared close flag has been set, the -/// head is amended with `Connection: close`, the buffer is flushed, and the -/// connection is closed once response `.end` is written. This protects against -/// clients that keep uploading request body bytes after the response has -/// completed (which would otherwise force the server to drain unbounded data) -/// and gives the client an explicit signal not to pipeline another request on -/// the connection. +/// At each deadline, if request `.end` has arrived, the buffer is flushed as-is and +/// the connection is reusable. If request `.end` has *not* arrived, the head is +/// amended with `Connection: close`, the buffer is flushed, and the connection is +/// closed once response `.end` is written. This protects against clients that keep +/// uploading request body bytes after the response has completed (which would +/// otherwise force the server to drain unbounded data) and gives the client an +/// explicit signal not to pipeline another request on the connection. /// /// Informational (1xx) responses pass through unchanged and do not affect buffering /// state. @@ -77,27 +71,12 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { /// `true` if we've committed to closing the connection after this response's /// `.end` is written. Set when the buffer is flushed while request `.end` has - /// not yet arrived (so we add `Connection: close`), or when the close flag - /// was set by ``NIOHTTPServer/ConnectionContext/signalConnectionClose()`` - /// while a response is in flight. Cleared when a new request begins. + /// not yet arrived (so we add `Connection: close`). Cleared when a new request + /// begins. private var closeAfterResponseEnd: Bool = false private var finalResponseState: FinalResponseState = .notStarted - /// Shared close flag. ``NIOHTTPServer/ConnectionContext/signalConnectionClose()`` - /// sets this synchronously; the handler observes it when writing the next - /// response head and amends with `Connection: close`, then closes the channel - /// after the response `.end`. - let closeFlag: NIOLockedValueBox - - init(closeFlag: NIOLockedValueBox) { - self.closeFlag = closeFlag - } - - private var closeSignalled: Bool { - self.closeFlag.withLockedValue { $0 } - } - func channelRead(context: ChannelHandlerContext, data: NIOAny) { let part = self.unwrapInboundIn(data) switch part { @@ -139,19 +118,9 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { return } if self.requestEndReceived { - // Request fully read; stream the response directly. If a close - // signal has been observed, amend the head with `Connection: close` - // and arrange to close once response `.end` is written. - let outboundPart: HTTPResponsePart - if self.closeSignalled, case .head(var response) = part { - response.headerFields[.connection] = "close" - outboundPart = .head(response) - self.closeAfterResponseEnd = true - } else { - outboundPart = part - } + // Request fully read; stream the response directly. self.finalResponseState = .streaming - context.write(self.wrapOutboundOut(outboundPart), promise: promise) + context.write(data, promise: promise) } else { // Start buffering with the head. Additional parts (body, end) the // handler may write before the next deadline are appended below. @@ -166,9 +135,8 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { case .streaming: context.write(data, promise: promise) if case .end = part, self.closeAfterResponseEnd { - // The head we flushed earlier carried `Connection: close`, or a - // close signal arrived after the head was flushed; close the - // connection now that the response is complete. + // The head we flushed earlier carried `Connection: close`; close + // the connection now that the response is complete. context.flush() context.close(mode: .output, promise: nil) } @@ -178,7 +146,7 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { func flush(context: ChannelHandlerContext) { // An upstream writer forced a flush. Same deadline as `channelReadComplete`: // release any buffered parts, with `Connection: close` if request `.end` - // hasn't arrived or close was signalled. + // hasn't arrived. if case .buffering = self.finalResponseState { self.flushBuffer(context: context) } @@ -186,13 +154,12 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { } /// Releases buffered response parts to the pipeline. If request `.end` has not - /// yet arrived, or if the shared close flag is set, amend the head with - /// `Connection: close` and arrange to close the connection once response `.end` - /// is written. + /// yet arrived, amend the head with `Connection: close` and arrange to close + /// the connection once response `.end` is written. private func flushBuffer(context: ChannelHandlerContext) { guard case .buffering(var head, let additional) = self.finalResponseState else { return } - if !self.requestEndReceived || self.closeSignalled { + if !self.requestEndReceived { // Amend the head with `Connection: close` before flushing. if case .head(var response) = head.part { response.headerFields[.connection] = "close" diff --git a/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift index 1515b69..ca07244 100644 --- a/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift +++ b/Sources/NIOHTTPServer/HTTPServerCapability+ConnectionCapabilities.swift @@ -41,21 +41,4 @@ extension HTTPServerCapability { /// The peer's validated certificate chain, when available. var peerCertificateChain: X509.ValidatedCertificateChain? { get async throws } } - - /// A request-context capability that lets a request handler signal that the - /// connection should close after the current response. - /// - /// Servers whose request context conforms to this capability allow handlers - /// to indicate that the underlying connection should be closed once the - /// in-flight response has been sent. Implementations make the signal - /// effective on a best-effort basis (HTTP/1.1 typically appends - /// `Connection: close` and closes the channel; HTTP/2 typically sends - /// `GOAWAY` and lets in-flight streams complete normally). - public protocol CloseableConnection: RequestContext { - /// Signal that the connection should close after the current response. - /// - /// Non-blocking and idempotent. Subsequent calls have no additional - /// effect. - func signalConnectionClose() - } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index a4fa8f6..1a756b2 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -23,8 +23,7 @@ extension NIOHTTPServer { /// A `Connection` is owned by exactly one ``NIOHTTPServerConnectionHandler/handleConnection(connection:context:)`` /// invocation. The handler typically passes it to ``handleRequests(handler:)``, /// which runs the request loop until the peer closes the connection, the - /// server shuts down, the handler signals close via - /// ``ConnectionContext/signalConnectionClose()``, or an error occurs. + /// server shuts down, or an error occurs. /// /// A handler that decides to terminate before any request can simply /// return without calling ``handleRequests(handler:)``: the `Connection` @@ -64,7 +63,6 @@ extension NIOHTTPServer { /// /// Each request received on this connection is dispatched to `handler`. The /// loop returns when the peer closes the connection, the server shuts down, - /// the handler signals close via ``ConnectionContext/signalConnectionClose()``, /// or an error occurs. public consuming func handleRequests( handler: Handler diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 2f0d214..d3be12a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -import NIOConcurrencyHelpers import NIOCore import NIOSSL public import X509 @@ -31,14 +30,12 @@ extension NIOHTTPServer { /// Connection-scoped state. /// /// Carries connection-scoped data such as the negotiated HTTP version, the - /// peer / local addresses, the peer's validated certificate chain (when - /// applicable), and the close-signal trigger that - /// ``signalConnectionClose()`` invokes. + /// peer / local addresses, and the peer's validated certificate chain (when + /// applicable). /// /// User code accesses this state via the corresponding ``RequestContext`` /// capabilities (``HTTPServerCapability/ConnectionInfo``, - /// ``HTTPServerCapability/PeerCertificate``, - /// ``HTTPServerCapability/CloseableConnection``) when handling individual + /// ``HTTPServerCapability/PeerCertificate``) when handling individual /// requests, and directly when implementing an /// ``NIOHTTPServerConnectionHandler``. public struct ConnectionContext: Sendable { @@ -53,31 +50,16 @@ extension NIOHTTPServer { var peerCertificateChainFuture: EventLoopFuture? - /// Per-protocol close mechanism. HTTP/1.1 uses a shared atomic flag the - /// channel's ``HTTPKeepAliveHandler`` reads when writing the next - /// response head. HTTP/2 fires `ChannelShouldQuiesceEvent` on the - /// connection channel; NIO's `NIOHTTP2ServerConnectionManagementHandler` - /// reacts by sending `GOAWAY`, letting in-flight streams complete - /// normally, and finally closing the connection. - let closeBacking: CloseBacking - - enum CloseBacking: Sendable { - case http1_1(closeFlag: NIOLockedValueBox) - case http2(connectionChannel: any Channel) - } - init( httpVersion: HTTPVersion, remoteAddress: NIOHTTPServer.SocketAddress? = nil, localAddress: NIOHTTPServer.SocketAddress? = nil, - peerCertificateChainFuture: EventLoopFuture? = nil, - closeBacking: CloseBacking + peerCertificateChainFuture: EventLoopFuture? = nil ) { self.httpVersion = httpVersion self.remoteAddress = remoteAddress self.localAddress = localAddress self.peerCertificateChainFuture = peerCertificateChainFuture - self.closeBacking = closeBacking } /// The peer's validated certificate chain. Returns `nil` if a custom @@ -92,22 +74,5 @@ extension NIOHTTPServer { return nil } } - - /// Signal that the connection should close after the current response. - /// - /// Non-blocking and idempotent. Effective after the current response: - /// - /// - On HTTP/1.1, the response carries `Connection: close` and the - /// channel is closed once the response has been written. - /// - On HTTP/2, the connection sends `GOAWAY`; in-flight streams - /// complete normally before the connection closes. - public func signalConnectionClose() { - switch self.closeBacking { - case .http1_1(let closeFlag): - closeFlag.withLockedValue { $0 = true } - case .http2(let connectionChannel): - connectionChannel.pipeline.fireUserInboundEventTriggered(ChannelShouldQuiesceEvent()) - } - } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 3da8c07..494f140 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -13,7 +13,6 @@ //===----------------------------------------------------------------------===// import Logging -import NIOConcurrencyHelpers import NIOCore import NIOExtras import NIOHTTP1 @@ -24,14 +23,6 @@ import NIOSSL @available(anyAppleOS 26.0, *) extension NIOHTTPServer { - /// An HTTP/1.1 connection vended by the accept loop: the async channel and - /// the close flag the channel's ``HTTPKeepAliveHandler`` shares with the - /// connection context. - struct HTTP1ChildConnection: Sendable { - let asyncChannel: NIOAsyncChannel - let closeFlag: NIOLockedValueBox - } - /// Serves incoming plaintext HTTP/1.1 connections. /// /// Each connection is handled concurrently in its own child task. Individual connection errors are handled within @@ -43,7 +34,7 @@ extension NIOHTTPServer { /// /// - Throws: If an error occurs while iterating the incoming connection stream. func serveInsecureHTTP1_1( - serverChannel: NIOAsyncChannel, + serverChannel: NIOAsyncChannel, Never>, connectionHandler: Handler ) async throws { try await serverChannel.executeThenClose { inbound in @@ -53,10 +44,10 @@ extension NIOHTTPServer { // `inbound`) must be caught and handled directly. let inboundConnectionIterationError = await withDiscardingTaskGroup { group -> (any Error)? in do { - for try await child in inbound { + for try await requestChannel in inbound { group.addTask { await self.dispatchPlaintextHTTP1_1Connection( - child: child, + requestChannel: requestChannel, connectionHandler: connectionHandler ) } @@ -83,14 +74,13 @@ extension NIOHTTPServer { /// `NIOAsyncWriter` is finished cleanly whether or not the connection /// handler called ``Connection/handleRequests(handler:)``. private func dispatchPlaintextHTTP1_1Connection( - child: sending HTTP1ChildConnection, + requestChannel: sending NIOAsyncChannel, connectionHandler: Handler ) async { do { - try await child.asyncChannel.executeThenClose { inbound, outbound in + try await requestChannel.executeThenClose { inbound, outbound in let context = NIOHTTPServer.makeHTTP1ConnectionContext( - requestChannel: child.asyncChannel, - closeFlag: child.closeFlag, + requestChannel: requestChannel, peerCertificateChainFuture: nil ) let connection = Connection( @@ -118,13 +108,13 @@ extension NIOHTTPServer { func setupHTTP1_1ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget] ) async throws -> [( - NIOAsyncChannel, ServerQuiescingHelper + NIOAsyncChannel, Never>, ServerQuiescingHelper )] { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) var serverChannels = [ - (NIOAsyncChannel, ServerQuiescingHelper) + (NIOAsyncChannel, Never>, ServerQuiescingHelper) ]() do { @@ -173,51 +163,37 @@ extension NIOHTTPServer { return serverChannels } - /// Configures the HTTP/1.1 server pipeline and the keep-alive handler, sharing - /// a fresh close flag between the keep-alive handler (which observes it when - /// writing the next response head) and the eventually-vended ``HTTP1ChildConnection``. + /// Configures the HTTP/1.1 server pipeline and the keep-alive handler. func setupHTTP1_1Connection( channel: any Channel, asyncChannelConfiguration: NIOAsyncChannel.Configuration, isSecure: Bool - ) -> EventLoopFuture { - let closeFlag = NIOLockedValueBox(false) - return channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { + ) -> EventLoopFuture> { + channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: isSecure)) - try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler(closeFlag: closeFlag)) + try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) try channel .pipeline .syncOperations .addTimeoutHandlers(self.configuration.connectionTimeouts) - let asyncChannel = try NIOAsyncChannel( + return try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: asyncChannelConfiguration ) - return HTTP1ChildConnection(asyncChannel: asyncChannel, closeFlag: closeFlag) } } /// Builds a ``ConnectionContext`` for an HTTP/1.1 request channel. - /// - /// The context's ``ConnectionContext/signalConnectionClose()`` synchronously - /// flips the shared close flag the channel's ``HTTPKeepAliveHandler`` - /// observes when writing the next response head. The synchronous set - /// side-steps any race between firing a NIO event off-loop and writing the - /// response head off-loop. The handler reacts by amending the next response - /// head with `Connection: close` and closing the channel once the response - /// `.end` is written. static func makeHTTP1ConnectionContext( requestChannel: NIOAsyncChannel, - closeFlag: NIOLockedValueBox, peerCertificateChainFuture: EventLoopFuture? ) -> ConnectionContext { ConnectionContext( httpVersion: .http1_1, remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), - peerCertificateChainFuture: peerCertificateChainFuture, - closeBacking: .http1_1(closeFlag: closeFlag) + peerCertificateChainFuture: peerCertificateChainFuture ) } @@ -229,10 +205,8 @@ extension NIOHTTPServer { /// The caller (the dispatcher) owns the channel's `executeThenClose`, /// so this method only iterates inbound requests and writes responses; /// it never closes the channel itself. The loop terminates when the - /// peer closes the connection, the task is cancelled, the handler - /// signals close (which causes the ``HTTPKeepAliveHandler`` to close - /// the channel after the response — the next iterator read then returns - /// `nil`), or an error occurs. + /// peer closes the connection, the task is cancelled, or an error + /// occurs. func handleHTTP1RequestLoop( inbound: NIOAsyncChannelInboundStream, outbound: NIOAsyncChannelOutboundWriter, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift index dadaab4..bb8eb70 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift @@ -22,7 +22,6 @@ extension NIOHTTPServer { /// Conforms to: /// - ``HTTPServerCapability/ConnectionInfo`` — peer / local addresses. /// - ``HTTPServerCapability/PeerCertificate`` — mTLS-validated peer chain. - /// - ``HTTPServerCapability/CloseableConnection`` — `signalConnectionClose()`. /// /// Generic library code can constrain on these capabilities to access /// per-request data without depending on ``NIOHTTPServer`` directly. @@ -66,18 +65,3 @@ extension NIOHTTPServer.RequestContext: HTTPServerCapability.PeerCertificate { } } } - -@available(anyAppleOS 26.0, *) -extension NIOHTTPServer.RequestContext: HTTPServerCapability.CloseableConnection { - /// Signal that the connection should close after the current response. - /// - /// Non-blocking and idempotent. Effective after the current response: - /// - /// - On HTTP/1.1, the response carries `Connection: close` and the - /// channel is closed once the response has been written. - /// - On HTTP/2, the connection sends `GOAWAY`; in-flight streams - /// complete normally before the connection closes. - public func signalConnectionClose() { - self.connectionContext.signalConnectionClose() - } -} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 8549193..c82363c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -30,7 +30,7 @@ import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { typealias NegotiatedChannel = NIONegotiatedHTTPVersion< - HTTP1ChildConnection, + NIOAsyncChannel, (any Channel, NIOHTTP2Handler.AsyncStreamMultiplexer>) > @@ -91,16 +91,15 @@ extension NIOHTTPServer { } switch negotiatedChannel { - case .http1_1(let child): + case .http1_1(let requestChannel): // The dispatcher owns the channel's `executeThenClose` so the // `NIOAsyncWriter` is finished cleanly whether or not the // connection handler called `handleRequests`. do { - try await child.asyncChannel.executeThenClose { inbound, outbound in - let chainFuture = child.asyncChannel.channel.nioSSL_peerValidatedCertificateChain() + try await requestChannel.executeThenClose { inbound, outbound in + let chainFuture = requestChannel.channel.nioSSL_peerValidatedCertificateChain() let context = NIOHTTPServer.makeHTTP1ConnectionContext( - requestChannel: child.asyncChannel, - closeFlag: child.closeFlag, + requestChannel: requestChannel, peerCertificateChainFuture: chainFuture ) let connection = Connection( @@ -147,13 +146,6 @@ extension NIOHTTPServer { } /// Builds a ``ConnectionContext`` for an HTTP/2 connection channel. - /// - /// The context's ``ConnectionContext/signalConnectionClose()`` fires - /// `ChannelShouldQuiesceEvent` on the connection-channel pipeline. NIO's - /// `NIOHTTP2ServerConnectionManagementHandler` (added to that pipeline by - /// `configureAsyncHTTP2Pipeline`) reacts by initiating graceful shutdown — - /// sending `GOAWAY`, letting in-flight streams complete normally, and - /// finally closing the connection. static func makeHTTP2ConnectionContext( connectionChannel: any Channel, peerCertificateChainFuture: EventLoopFuture? @@ -162,8 +154,7 @@ extension NIOHTTPServer { httpVersion: .http2, remoteAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.remoteAddress), localAddress: try? NIOHTTPServer.SocketAddress(connectionChannel.localAddress), - peerCertificateChainFuture: peerCertificateChainFuture, - closeBacking: .http2(connectionChannel: connectionChannel) + peerCertificateChainFuture: peerCertificateChainFuture ) } @@ -383,13 +374,6 @@ extension NIOHTTPServer { } /// Handles an HTTP/2 stream channel, which carries exactly one request per stream. - /// - /// If the request handler invokes - /// ``ConnectionContext/signalConnectionClose()`` while running, the - /// connection-context's `closeAction` fires `ChannelShouldQuiesceEvent` on - /// the connection-channel pipeline; NIO's HTTP/2 connection management - /// handler reacts by sending `GOAWAY`, letting other in-flight streams - /// complete normally, and finally closing the connection. func handleHTTP2StreamChannel( channel: NIOAsyncChannel, handler: Handler, diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift index e5c6a2e..ae65104 100644 --- a/Sources/NIOHTTPServer/ServerChannel.swift +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -22,7 +22,7 @@ extension NIOHTTPServer { /// Upgrade. enum ServerChannel { case plaintextHTTP1_1( - channel: NIOAsyncChannel, + channel: NIOAsyncChannel, Never>, quiescingHelper: ServerQuiescingHelper ) diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift index 8d23ee1..cfcd660 100644 --- a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -293,291 +293,6 @@ struct ConnectionLifecycleTests { #expect(state.observedFinalCounter.withLockedValue { $0 } == numStreams) } - /// HTTP/1.1: a request handler that signals close has its response carry - /// `Connection: close`, and a follow-up read on the same socket returns nil. - @available(anyAppleOS 26.0, *) - @Test("signalConnectionClose() on HTTP/1.1") - func testSignalConnectionCloseHTTP1_1() async throws { - let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) - - let connectionHandler = NIOHTTPServerDefaultConnectionHandler( - handler: HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in - requestContext.signalConnectionClose() - var buffer = UniqueArray(copying: []) - try await responseSender.sendAndFinish(.init(status: .unauthorized), buffer: &buffer) - } - ) - - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) - try await outbound.write(.end(nil)) - - var iterator = inbound.makeAsyncIterator() - let head = try await iterator.next() - guard case .head(let response) = head else { - Issue.record("Expected response head but got \(String(describing: head))") - return - } - #expect(response.status == .unauthorized) - #expect(response.headerFields[.connection] == "close") - - while let part = try await iterator.next() { - if case .end = part { break } - } - - let afterEnd = try await iterator.next() - #expect(afterEnd == nil) - } - } - } - - /// HTTP/2: a request handler that signals close causes the connection to - /// stop accepting new streams. In-flight streams complete normally before - /// the connection is torn down. - @available(anyAppleOS 26.0, *) - @Test("signalConnectionClose() on HTTP/2") - func testSignalConnectionCloseHTTP2() async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) - let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup - let numInflight = 3 - let allRequestsReceived = elg.any().makePromise(of: Void.self) - let arrivedCounter = NIOLockedValueBox(0) - - let connectionHandler = NIOHTTPServerDefaultConnectionHandler( - handler: HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in - let arrived = arrivedCounter.withLockedValue { value -> Int in - value += 1 - return value - } - if arrived == numInflight { - requestContext.signalConnectionClose() - allRequestsReceived.succeed() - } else { - try await allRequestsReceived.futureResult.get() - } - var buffer = UniqueArray(copying: []) - try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) - } - ) - - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in - let clientChannel = try await ClientBootstrap(group: elg) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: HTTPVersion.http2.alpnIdentifier - ) - guard case .http2(let streamManager) = clientChannel else { - Issue.record("Expected HTTP/2 channel, got \(clientChannel).") - return - } - - try await withThrowingTaskGroup { group in - for _ in 1...numInflight { - group.addTask { - let stream = try await streamManager.openStream() - try await stream.executeThenClose { inbound, outbound in - try await outbound.write( - .head(.init(method: .get, scheme: "https", authority: "", path: "/")) - ) - try await outbound.write(.end(nil)) - var iterator = inbound.makeAsyncIterator() - var sawHead = false - while let part = try await iterator.next() { - if case .head(let response) = part { - #expect(response.status == .ok) - sawHead = true - } - if case .end = part { break } - } - #expect(sawHead, "All in-flight streams should complete normally with status ok") - } - } - } - try await group.waitForAll() - } - } - } - - /// HTTP/1.1: calling `signalConnectionClose()` twice within a single request - /// handler is harmless — the response still carries a single - /// `Connection: close` header and the channel is closed exactly once. - @available(anyAppleOS 26.0, *) - @Test("signalConnectionClose() is idempotent (HTTP/1.1)") - func testSignalConnectionCloseIdempotent() async throws { - let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) - - let connectionHandler = NIOHTTPServerDefaultConnectionHandler( - handler: HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in - requestContext.signalConnectionClose() - requestContext.signalConnectionClose() - requestContext.signalConnectionClose() - var buffer = UniqueArray(copying: []) - try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) - } - ) - - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) - try await outbound.write(.end(nil)) - - var iterator = inbound.makeAsyncIterator() - let head = try await iterator.next() - guard case .head(let response) = head else { - Issue.record("Expected response head but got \(String(describing: head))") - return - } - #expect(response.headerFields[.connection] == "close") - // Look for any duplicate `Connection` headers; the field should - // appear exactly once with value "close". - let connectionValues = response.headerFields[values: .connection] - #expect(connectionValues == ["close"]) - - while let part = try await iterator.next() { - if case .end = part { break } - } - let _trailing = try await iterator.next() - #expect(_trailing == nil) - } - } - } - - /// HTTP/1.1: calling `signalConnectionClose()` AFTER the response head has - /// already been written. The channel still closes after the response `.end`, - /// but the head can't be amended — it's already on the wire. The client - /// observes the response without a `Connection: close` header followed by - /// EOF. - @available(anyAppleOS 26.0, *) - @Test("signalConnectionClose() after response head is written (HTTP/1.1)") - func testSignalConnectionCloseAfterHead() async throws { - let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) - - let connectionHandler = NIOHTTPServerDefaultConnectionHandler( - handler: HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in - let writer = try await responseSender.send(.init(status: .ok)) - requestContext.signalConnectionClose() - var buffer = UniqueArray(copying: [0x21]) - try await writer.finish(buffer: &buffer, finalElement: nil) - } - ) - - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in - let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .get, scheme: "http", authority: "", path: "/"))) - try await outbound.write(.end(nil)) - - var iterator = inbound.makeAsyncIterator() - let head = try await iterator.next() - guard case .head(let response) = head else { - Issue.record("Expected response head but got \(String(describing: head))") - return - } - #expect(response.status == .ok) - // We deliberately don't assert anything about the `Connection` - // header here: `responseSender.send(_:)` returns once the head - // is yielded to the async-stream queue, which may be before the - // keep-alive handler has actually processed it on the event - // loop. So the flag set immediately after `send` is sometimes - // still observed in time to amend the head. The reliable - // observable is that the channel closes after the response. - - while let part = try await iterator.next() { - if case .end = part { break } - } - let _trailing = try await iterator.next() - #expect(_trailing == nil) - } - } - } - - /// HTTP/2: after a stream signals close, the connection drains existing - /// streams and rejects new stream creation. Opening a stream against the - /// already-quiesced connection fails (NIOHTTP2's GOAWAY mechanics). - /// TODO: this test is disabled since it's currently flaky; because of a bug in NIO where inbound NIOAsyncChannels are - /// dropped if not consumed, and a precondition fails because they've not been finished. - @available(anyAppleOS 26.0, *) - @Test("HTTP/2 rejects new streams after signalConnectionClose()", .disabled()) - func testHTTP2RejectsNewStreamsAfterClose() async throws { - let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) - let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup - - let connectionHandler = NIOHTTPServerDefaultConnectionHandler( - handler: HTTPServerClosureRequestHandler { - request, - requestContext, - reader, - responseSender in - requestContext.signalConnectionClose() - var buffer = UniqueArray(copying: []) - try await responseSender.sendAndFinish(.init(status: .ok), buffer: &buffer) - } - ) - - try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in - let clientChannel = try await ClientBootstrap(group: elg) - .connectToTestSecureUpgradeHTTPServer( - at: serverAddress, - trustRoots: serverChain.chain, - applicationProtocol: HTTPVersion.http2.alpnIdentifier - ) - guard case .http2(let streamManager) = clientChannel else { - Issue.record("Expected HTTP/2 channel, got \(clientChannel).") - return - } - - // First stream completes normally and signals connection close. - let stream = try await streamManager.openStream() - try await stream.executeThenClose { inbound, outbound in - try await outbound.write(.head(.init(method: .get, scheme: "https", authority: "", path: "/"))) - try await outbound.write(.end(nil)) - var iterator = inbound.makeAsyncIterator() - while let part = try await iterator.next() { - if case .end = part { break } - } - } - - // After the close signal has been observed by the connection-management - // handler, opening a new stream should fail. We poll for a short - // window since the GOAWAY processing is asynchronous to the response - // we just observed. - var rejected = false - for _ in 0..<50 { - do { - _ = try await streamManager.openStream() - } catch { - rejected = true - break - } - try await Task.sleep(for: .milliseconds(50)) - } - #expect(rejected, "Expected new-stream creation to fail after GOAWAY.") - } - } - /// A throwing connection handler is logged at debug level by the server but /// doesn't bring it down: a subsequent connection on the same server is /// served normally. diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index a6a03ab..9efd034 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -33,7 +33,7 @@ extension NIOHTTPServer { { // The server requires a NIOAsyncChannel, so we create one from the test channel let serverTestAsyncChannel = try await testChannel.eventLoop.submit { - try NIOAsyncChannel( + try NIOAsyncChannel, Never>( wrappingChannelSynchronously: testChannel, configuration: .init() ) From 67c7bcddca814417ad0dba03bc6b5a83cc5fc42f Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Mon, 6 Jul 2026 11:39:11 +0100 Subject: [PATCH 6/8] Format --- Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift index 6de49f6..149f992 100644 --- a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -70,7 +70,7 @@ struct ConnectionHandlerExample { var connection = Optional(connection) try await withLogger(mergingMetadata: [ "peer": .string(context.remoteAddress.map { "\($0)" } ?? "unknown"), - "http": .string(context.httpVersion.rawValue) + "http": .string(context.httpVersion.rawValue), ]) { connectionLogger in connectionLogger.info("connection accepted") defer { connectionLogger.info("connection closed") } From 874d77c0d27d2ed66fa6e9f3af26dcc85a5e26f7 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Jul 2026 15:53:25 +0100 Subject: [PATCH 7/8] PR changes --- .../ConnectionHandlerExample.swift | 2 +- .../NIOHTTPServer+Connection.swift | 7 +- .../NIOHTTPServer+SecureUpgrade.swift | 4 +- ...IOHTTPServerDefaultConnectionHandler.swift | 2 +- .../ConnectionLifecycleTests.swift | 83 +++++++++++++++---- 5 files changed, 76 insertions(+), 22 deletions(-) diff --git a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift index 149f992..f1b45a0 100644 --- a/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift +++ b/Sources/ConnectionHandlerExample/ConnectionHandlerExample.swift @@ -75,7 +75,7 @@ struct ConnectionHandlerExample { connectionLogger.info("connection accepted") defer { connectionLogger.info("connection closed") } - try await connection.take()!.handleRequests { request, _, _, responseSender in + await connection.take()!.handleRequests { request, _, _, responseSender in var responseSender = Optional(responseSender) try await withLogger(mergingMetadata: [ "path": .string(request.path ?? "") diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index 1a756b2..c8b6077 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -66,8 +66,7 @@ extension NIOHTTPServer { /// or an error occurs. public consuming func handleRequests( handler: Handler - ) async throws - where + ) async where Handler.RequestContext == NIOHTTPServer.RequestContext, Handler.Reader == NIOHTTPServer.Reader, Handler.ResponseSender == NIOHTTPServer.ResponseSender @@ -111,8 +110,8 @@ extension NIOHTTPServer { _ reader: consuming sending NIOHTTPServer.Reader, _ responseSender: consuming sending NIOHTTPServer.ResponseSender ) async throws -> Void - ) async throws { - try await self.handleRequests( + ) async { + await self.handleRequests( handler: HTTPServerClosureRequestHandler< NIOHTTPServer.RequestContext, NIOHTTPServer.Reader, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index c82363c..f5cf9e5 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -118,7 +118,7 @@ extension NIOHTTPServer { } } catch { self.logger.debug( - "Error tearing down HTTP/1.1 channel", + "Error handling HTTP/1.1 connection", metadata: ["error": "\(error)"] ) } @@ -134,6 +134,8 @@ extension NIOHTTPServer { context: context, httpProtocol: .http2(connectionChannel: connectionChannel, multiplexer: multiplexer) ) + + defer { try? await connectionChannel.close() } do { try await connectionHandler.handleConnection(connection: connection, context: context) } catch { diff --git a/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift index 5b6a5f1..c452ab6 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerDefaultConnectionHandler.swift @@ -41,6 +41,6 @@ where connection: consuming sending NIOHTTPServer.Connection, context: NIOHTTPServer.ConnectionContext ) async throws { - try await connection.handleRequests(handler: self.handler) + await connection.handleRequests(handler: self.handler) } } diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift index cfcd660..777207a 100644 --- a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -59,7 +59,7 @@ where globalCounter: self.state.requestInvocations, localCounter: connectionLocalCounter ) - try await connection.handleRequests(handler: countingHandler) + await connection.handleRequests(handler: countingHandler) self.state.observedFinalCounter.withLockedValue { $0 = connectionLocalCounter.withLockedValue { $0 } } } @@ -147,7 +147,7 @@ struct ConnectionLifecycleTests { /// HTTP/1.1: connecting twice results in two `handleConnection` invocations, /// each with non-nil `remoteAddress` and `localAddress`. @available(anyAppleOS 26.0, *) - @Test("serve(connectionHandler:) — per-connection invocation count (HTTP/1.1)") + @Test("serve(connectionHandler:) — per-connection invocation count (HTTP/1.1)", .timeLimit(.minutes(1))) func testPerConnectionInvocationHTTP1_1() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let state = ConnectionLifecycleTestState() @@ -180,7 +180,7 @@ struct ConnectionLifecycleTests { /// HTTP/1.1 keep-alive: two requests on the same connection result in a /// single `handleConnection` invocation that runs the request handler twice. @available(anyAppleOS 26.0, *) - @Test("HTTP/1.1 keep-alive — single connection-handler invocation, multiple requests") + @Test("HTTP/1.1 keep-alive — single connection-handler invocation, multiple requests", .timeLimit(.minutes(1))) func testKeepAliveSingleInvocationMultipleRequests() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let state = ConnectionLifecycleTestState() @@ -215,7 +215,7 @@ struct ConnectionLifecycleTests { /// held by the connection handler observes three after `handleRequests` /// returns. @available(anyAppleOS 26.0, *) - @Test("HTTP/2 — single connection-handler invocation, concurrent streams") + @Test("HTTP/2 — single connection-handler invocation, concurrent streams", .timeLimit(.minutes(1))) func testHTTP2SingleInvocationConcurrentStreams() async throws { let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup @@ -297,7 +297,7 @@ struct ConnectionLifecycleTests { /// doesn't bring it down: a subsequent connection on the same server is /// served normally. @available(anyAppleOS 26.0, *) - @Test("Throwing connection handler doesn't bring down the server") + @Test("Throwing connection handler doesn't bring down the server", .timeLimit(.minutes(1))) func testThrowingConnectionHandlerDoesNotKillServer() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let connectionInvocations = NIOLockedValueBox(0) @@ -344,7 +344,7 @@ struct ConnectionLifecycleTests { /// effectively drops the connection: the channel closes immediately and /// the client sees EOF without any response. @available(anyAppleOS 26.0, *) - @Test("Connection handler returning without handleRequests drops the connection") + @Test("Connection handler returning without handleRequests drops the connection", .timeLimit(.minutes(1))) func testConnectionHandlerEarlyReturn() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let connectionInvocations = NIOLockedValueBox(0) @@ -377,11 +377,64 @@ struct ConnectionLifecycleTests { #expect(connectionInvocations.withLockedValue { $0 } == 1) } + /// HTTP/2 counterpart: a connection handler that returns without calling + /// `handleRequests` still causes the underlying connection channel to be + /// closed by the dispatcher. Without the dispatcher's explicit close, the + /// multiplexer's underlying `NIOAsyncChannel` would deinit with an + /// unfinalized writer and trip the `NIOAsyncWriter` precondition — since + /// nothing else on our side references the channel in the early-return + /// path. + @available(anyAppleOS 26.0, *) + @Test("Connection handler returning without handleRequests drops the HTTP/2 connection", .timeLimit(.minutes(1))) + func testConnectionHandlerEarlyReturnHTTP2() async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) + let elg: EventLoopGroup = .singletonMultiThreadedEventLoopGroup + let connectionInvocations = NIOLockedValueBox(0) + + let connectionHandler = NoOpConnectionHandler(connectionInvocations: connectionInvocations) + + try await Self.withServer(server: server, connectionHandler: connectionHandler) { serverAddress in + let clientChannel = try await ClientBootstrap(group: elg) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + trustRoots: serverChain.chain, + applicationProtocol: HTTPVersion.http2.alpnIdentifier + ) + guard case .http2(let streamManager) = clientChannel else { + Issue.record("Expected HTTP/2 channel, got \(clientChannel).") + return + } + + // The server side drops the connection right after `handleConnection` + // returns. Any stream we try to open should either fail outright, or + // succeed transiently and then error/EOF once the server's close + // reaches the client. Both outcomes are valid evidence that the + // connection was closed. + var sawResponseHead = false + do { + let stream = try await streamManager.openStream() + try await stream.executeThenClose { inbound, outbound in + try? await outbound.write(.head(.init(method: .get, scheme: "https", authority: "", path: "/"))) + try? await outbound.write(.end(nil)) + var iterator = inbound.makeAsyncIterator() + while let part = try? await iterator.next() { + if case .head = part { sawResponseHead = true } + } + } + } catch { + // Stream open / write / read may throw — all valid outcomes. + } + #expect(!sawResponseHead, "Expected no response head from a dropped connection.") + } + + #expect(connectionInvocations.withLockedValue { $0 } == 1) + } + /// `ConnectionContext.httpVersion` reflects the protocol negotiated for the /// connection. Verified for plaintext HTTP/1.1, secure-upgrade-negotiated /// HTTP/1.1, and secure-upgrade-negotiated HTTP/2. @available(anyAppleOS 26.0, *) - @Test("ConnectionContext.httpVersion for plaintext HTTP/1.1") + @Test("ConnectionContext.httpVersion for plaintext HTTP/1.1", .timeLimit(.minutes(1))) func testHTTPVersionPlaintextHTTP1_1() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let observed = NIOLockedValueBox(nil) @@ -409,7 +462,7 @@ struct ConnectionLifecycleTests { } @available(anyAppleOS 26.0, *) - @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/1.1") + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/1.1", .timeLimit(.minutes(1))) func testHTTPVersionSecureUpgradeHTTP1_1() async throws { let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) let observed = NIOLockedValueBox(nil) @@ -445,7 +498,7 @@ struct ConnectionLifecycleTests { } @available(anyAppleOS 26.0, *) - @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/2") + @Test("ConnectionContext.httpVersion for secure-upgrade HTTP/2", .timeLimit(.minutes(1))) func testHTTPVersionSecureUpgradeHTTP2() async throws { let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: Self.serverLogger) let observed = NIOLockedValueBox(nil) @@ -486,7 +539,7 @@ struct ConnectionLifecycleTests { /// shared between them — each connection's per-request counter only /// reflects its own requests. @available(anyAppleOS 26.0, *) - @Test("State isolation across keep-alive HTTP/1.1 connections") + @Test("State isolation across keep-alive HTTP/1.1 connections", .timeLimit(.minutes(1))) func testKeepAliveStateIsolationAcrossConnections() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) let perConnectionCounters = NIOLockedValueBox<[Int]>([]) @@ -533,7 +586,7 @@ struct ConnectionLifecycleTests { /// single request is served end-to-end without constructing an explicit /// ``HTTPServerClosureRequestHandler``. @available(anyAppleOS 26.0, *) - @Test("connection.handleRequests closure overload") + @Test("connection.handleRequests closure overload", .timeLimit(.minutes(1))) func testHandleRequestsClosureOverload() async throws { let server = try NIOHTTPServerTests.makePlaintextHTTP1Server(logger: Self.serverLogger) @@ -583,7 +636,7 @@ struct ThrowingFirstConnectionHandler: NIOHTTPServerConnectionHandler { if invocation == 1 { throw TestError.intentional } - try await connection.handleRequests { request, _, reader, sender in + await connection.handleRequests { request, _, reader, sender in try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) } } @@ -625,7 +678,7 @@ where self.observed.withLockedValue { value in if value == nil { value = context.httpVersion } } - try await connection.handleRequests(handler: self.wrappedHandler) + await connection.handleRequests(handler: self.wrappedHandler) } } @@ -651,7 +704,7 @@ where globalCounter: NIOLockedValueBox(0), localCounter: counter ) - try await connection.handleRequests(handler: countingHandler) + await connection.handleRequests(handler: countingHandler) let finalCount = counter.withLockedValue { $0 } self.perConnectionCounters.withLockedValue { $0.append(finalCount) } } @@ -665,7 +718,7 @@ struct ClosureRequestHandlerConnectionHandler: NIOHTTPServerConnectionHandler { connection: consuming sending NIOHTTPServer.Connection, context: NIOHTTPServer.ConnectionContext ) async throws { - try await connection.handleRequests { request, _, reader, sender in + await connection.handleRequests { request, _, reader, sender in try await NIOHTTPServerTests.echoResponse(readUpTo: 1024, reader: reader, sender: sender) } } From ed2fbc4b672a47f626b07ab06bebfec96af5a502 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Jul 2026 15:55:25 +0100 Subject: [PATCH 8/8] Format --- Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index c8b6077..09964da 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -66,7 +66,8 @@ extension NIOHTTPServer { /// or an error occurs. public consuming func handleRequests( handler: Handler - ) async where + ) async + where Handler.RequestContext == NIOHTTPServer.RequestContext, Handler.Reader == NIOHTTPServer.Reader, Handler.ResponseSender == NIOHTTPServer.ResponseSender