diff --git a/Package.swift b/Package.swift index 7e4e948..cc27c04 100644 --- a/Package.swift +++ b/Package.swift @@ -40,7 +40,7 @@ let package = Package( .default(enabledTraits: ["Configuration"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-http-api-proposal", branch: "main"), + .package(url: "https://github.com/apple/swift-http-api-proposal.git", branch: "main"), .package( url: "https://github.com/FranzBusch/swift-collections.git", branch: "fb-async" @@ -52,8 +52,9 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.92.2"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), - .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.40.0"), + .package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.10.0"), ], targets: [ .executableTarget( @@ -91,6 +92,7 @@ let package = Package( package: "swift-configuration", condition: .when(traits: ["Configuration"]) ), + .product(name: "NIOExtras", package: "swift-nio-extras"), .product(name: "HTTPServer", package: "swift-http-api-proposal"), ], swiftSettings: extraSettings @@ -99,6 +101,8 @@ let package = Package( name: "NIOHTTPServerTests", dependencies: [ .product(name: "Logging", package: "swift-log"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "NIOHTTPServer", ] ), diff --git a/README.md b/README.md index d85ac67..c77a295 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,12 @@ All feedback is welcome: please open issues! ## Getting started To get started, please refer to the project's documentation and the Example located under `Sources`. + +## Package traits + +This package offers additional integrations you can enable using +[package traits](https://docs.swift.org/swiftpm/documentation/packagemanagerdocs/addingdependencies#Packages-with-Traits). + +Available traits: +- **`Configuration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` + `ConfigProvider`. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index cae2913..38b8494 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -15,6 +15,7 @@ import HTTPServer import NIOCore import NIOEmbedded +import NIOExtras import NIOHTTP1 import NIOHTTPTypes import NIOHTTPTypesHTTP1 @@ -23,19 +24,24 @@ import NIOPosix @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { func serveInsecureHTTP1_1( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - handler: some HTTPServerRequestHandler, - asyncChannelConfiguration: NIOAsyncChannel.Configuration + serverChannel: NIOAsyncChannel, Never>, + handler: some HTTPServerRequestHandler ) async throws { - let serverChannel = try await self.setupHTTP1_1ServerChannel( - bindTarget: bindTarget, - asyncChannelConfiguration: asyncChannelConfiguration - ) - - try await _serveInsecureHTTP1_1(serverChannel: serverChannel, handler: handler) + try await withThrowingDiscardingTaskGroup { group in + try await serverChannel.executeThenClose { inbound in + for try await http1Channel in inbound { + group.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } + } + } + } } - private func setupHTTP1_1ServerChannel( + func setupHTTP1_1ServerChannel( bindTarget: NIOHTTPServerConfiguration.BindTarget, asyncChannelConfiguration: NIOAsyncChannel.Configuration ) async throws -> NIOAsyncChannel, Never> { @@ -43,6 +49,13 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + } + } .bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( channel: channel, @@ -69,22 +82,4 @@ extension NIOHTTPServer { ) } } - - func _serveInsecureHTTP1_1( - serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler - ) async throws { - try await withThrowingDiscardingTaskGroup { group in - try await serverChannel.executeThenClose { inbound in - for try await http1Channel in inbound { - group.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) - } - } - } - } - } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 0952be9..39b0b03 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -16,6 +16,8 @@ import HTTPServer import Logging import NIOCore import NIOEmbedded +import NIOExtras +import NIOHTTP1 import NIOHTTP2 import NIOHTTPTypes import NIOHTTPTypesHTTP1 @@ -26,100 +28,12 @@ import X509 @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { - func serveSecureUpgrade( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - tlsConfiguration: TLSConfiguration, - handler: some HTTPServerRequestHandler, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil - ) async throws { - let serverChannel = try await self.setupSecureUpgradeServerChannel( - bindTarget: bindTarget, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Configuration, - verificationCallback: verificationCallback - ) - - try await self._serveSecureUpgrade(serverChannel: serverChannel, handler: handler) - } - typealias NegotiatedChannel = NIONegotiatedHTTPVersion< NIOAsyncChannel, (any Channel, NIOHTTP2Handler.AsyncStreamMultiplexer>) > - private func setupSecureUpgradeServerChannel( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - tlsConfiguration: TLSConfiguration, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? - ) async throws -> NIOAsyncChannel, Never> { - switch bindTarget.backing { - case .hostAndPort(let host, let port): - let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .bind(host: host, port: port) { channel in - self.setupSecureUpgradeConnectionChildChannel( - channel: channel, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Configuration, - verificationCallback: verificationCallback - ) - } - - try self.addressBound(serverChannel.channel.localAddress) - - return serverChannel - } - } - - func setupSecureUpgradeConnectionChildChannel( - channel: any Channel, - tlsConfiguration: TLSConfiguration, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? - ) -> EventLoopFuture> { - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler( - self.makeSSLServerHandler(tlsConfiguration, verificationCallback) - ) - }.flatMap { - channel.configureAsyncHTTPServerPipeline( - http2Configuration: http2Configuration, - http1ConnectionInitializer: { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: asyncChannelConfiguration - ) - } - }, - http2ConnectionInitializer: { channel in channel.eventLoop.makeCompletedFuture(.success(channel)) }, - http2StreamInitializer: { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations - .addHandler( - HTTP2FramePayloadToHTTPServerCodec() - ) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: asyncChannelConfiguration - ) - } - } - ) - } - } - - func _serveSecureUpgrade( + func serveSecureUpgrade( serverChannel: NIOAsyncChannel, Never>, handler: some HTTPServerRequestHandler ) async throws { @@ -166,6 +80,100 @@ extension NIOHTTPServer { } } } + + func setupSecureUpgradeServerChannel( + bindTarget: NIOHTTPServerConfiguration.BindTarget, + tlsConfiguration: TLSConfiguration, + asyncChannelConfiguration: NIOAsyncChannel.Configuration, + http2Configuration: NIOHTTPServerConfiguration.HTTP2, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) async throws -> NIOAsyncChannel, Never> { + switch bindTarget.backing { + case .hostAndPort(let host, let port): + let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + } + } + .bind(host: host, port: port) { channel in + self.setupSecureUpgradeConnectionChildChannel( + channel: channel, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: http2Configuration, + verificationCallback: verificationCallback + ) + } + + try self.addressBound(serverChannel.channel.localAddress) + + return serverChannel + } + } + + func setupSecureUpgradeConnectionChildChannel( + channel: any Channel, + tlsConfiguration: TLSConfiguration, + asyncChannelConfiguration: NIOAsyncChannel.Configuration, + http2Configuration: NIOHTTPServerConfiguration.HTTP2, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) -> EventLoopFuture> { + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.makeSSLServerHandler(tlsConfiguration, verificationCallback) + ) + }.flatMap { + channel.configureHTTP2AsyncSecureUpgrade( + http1ConnectionInitializer: { http1Channel in + http1Channel.pipeline.configureHTTPServerPipeline().flatMap { _ in + http1Channel.eventLoop.makeCompletedFuture { + try http1Channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: http1Channel, + configuration: asyncChannelConfiguration + ) + } + } + }, + http2ConnectionInitializer: { http2Channel in + http2Channel.eventLoop.makeCompletedFuture { + try http2Channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + mode: .server, + connectionManagerConfiguration: .init( + maxIdleTime: nil, + maxAge: nil, + maxGraceTime: http2Configuration.gracefulShutdown.maximumGracefulShutdownDuration + .map { TimeAmount($0) }, + keepalive: nil + ), + http2HandlerConfiguration: .init(httpServerHTTP2Configuration: http2Configuration), + streamInitializer: { http2StreamChannel in + http2StreamChannel.eventLoop.makeCompletedFuture { + try http2StreamChannel.pipeline.syncOperations + .addHandler( + HTTP2FramePayloadToHTTPServerCodec() + ) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: http2StreamChannel, + configuration: asyncChannelConfiguration + ) + } + } + ) + } + .flatMap { multiplexer in + http2Channel.eventLoop.makeCompletedFuture(.success((http2Channel, multiplexer))) + } + } + ) + } + } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift index 12ae6df..0bff668 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift @@ -14,7 +14,9 @@ #if Configuration public import Configuration +import NIOCore import NIOCertificateReloading +import NIOHTTP2 import SwiftASN1 public import X509 @@ -280,7 +282,7 @@ extension NIOHTTPServerConfiguration.HTTP2 { /// Initialize a HTTP/2 configuration from a config reader. /// /// ## Configuration keys: - /// - `maxFrameSize` (int, optional, default: 2^14): The maximum frame size to be used in an HTTP/2 connection. + /// - `maxFrameSize` (int, optional, default: 2^14): The maximum frame size to be used in an HTTP/2 connection. /// - `targetWindowSize` (int, optional, default: 2^16 - 1): The target window size to be used in an HTTP/2 /// connection. /// - `maxConcurrentStreams` (int, optional, default: 100): The maximum number of concurrent streams in an HTTP/2 @@ -300,7 +302,24 @@ extension NIOHTTPServerConfiguration.HTTP2 { /// The default value, ``NIOHTTPServerConfiguration.HTTP2.DEFAULT_TARGET_WINDOW_SIZE``, is `nil`. However, /// we can only specify a non-nil `default` argument to `config.int(...)`. But `config.int(...)` already /// defaults to `nil` if it can't find the `"maxConcurrentStreams"` key, so that works for us. - maxConcurrentStreams: config.int(forKey: "maxConcurrentStreams") + maxConcurrentStreams: config.int(forKey: "maxConcurrentStreams"), + gracefulShutdown: .init(config: config) + ) + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { + /// Initialize a HTTP/2 graceful shutdown configuration from a config reader. + /// + /// ## Configuration keys: + /// - `maximumGracefulShutdownDuration` (int, optional, default: nil): The maximum amount of time (in seconds) that + /// the connection has to close gracefully. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + maximumGracefulShutdownDuration: config.int(forKey: "maximumGracefulShutdownDuration").map { .seconds($0) } ) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 1f94f88..2d4e330 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -18,6 +18,7 @@ public import Logging import NIOCertificateReloading import NIOConcurrencyHelpers import NIOCore +import NIOExtras import NIOHTTP1 import NIOHTTP2 import NIOHTTPTypes @@ -25,6 +26,7 @@ import NIOHTTPTypesHTTP1 import NIOHTTPTypesHTTP2 import NIOPosix import NIOSSL +import ServiceLifecycle import SwiftASN1 import Synchronization import X509 @@ -85,6 +87,8 @@ public struct NIOHTTPServer: HTTPServer { let logger: Logger private let configuration: NIOHTTPServerConfiguration + let serverQuiescingHelper: ServerQuiescingHelper + var listeningAddressState: NIOLockedValueBox /// Task-local storage for connection-specific information accessible from request handlers. @@ -106,6 +110,8 @@ public struct NIOHTTPServer: HTTPServer { // TODO: If we allow users to pass in an event loop, use that instead of the singleton MTELG. let eventLoopGroup: MultiThreadedEventLoopGroup = .singletonMultiThreadedEventLoopGroup self.listeningAddressState = .init(.idle(eventLoopGroup.any().makePromise())) + + self.serverQuiescingHelper = .init(group: eventLoopGroup) } /// Starts an HTTP server with the specified request handler. @@ -149,15 +155,22 @@ public struct NIOHTTPServer: HTTPServer { public func serve( handler: some HTTPServerRequestHandler ) async throws { - defer { - switch self.listeningAddressState.withLockedValue({ $0.close() }) { - case .failPromise(let promise, let error): - promise.fail(error) - case .doNothing: - () + let serverChannel = try await self.makeServerChannel() + + return try await withTaskCancellationHandler { + try await withGracefulShutdownHandler { + try await self._serve(serverChannel: serverChannel, handler: handler) + } onGracefulShutdown: { + self.beginGracefulShutdown() } + } onCancel: { + // Forcefully close down the server channel + self.close(serverChannel: serverChannel) } + } + /// Creates and returns a server channel based on the configured transport security. + private func makeServerChannel() async throws -> ServerChannel { let asyncChannelConfiguration: NIOAsyncChannel.Configuration switch self.configuration.backpressureStrategy.backing { case .watermark(let low, let high): @@ -169,17 +182,14 @@ public struct NIOHTTPServer: HTTPServer { switch self.configuration.transportSecurity.backing { case .plaintext: - try await self.serveInsecureHTTP1_1( - bindTarget: self.configuration.bindTarget, - handler: handler, - asyncChannelConfiguration: asyncChannelConfiguration + return .plaintextHTTP1( + try await self.setupHTTP1_1ServerChannel( + bindTarget: self.configuration.bindTarget, + asyncChannelConfiguration: asyncChannelConfiguration + ) ) case .tls(let certificateChain, let privateKey): - let http2Config = NIOHTTP2Handler.Configuration( - httpServerHTTP2Configuration: self.configuration.http2 - ) - let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } let privateKey = try NIOSSLPrivateKeySource(privateKey) @@ -189,37 +199,33 @@ public struct NIOHTTPServer: HTTPServer { ) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - try await self.serveSecureUpgrade( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - handler: handler, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + return .secureUpgrade( + try await self.setupSecureUpgradeServerChannel( + bindTarget: self.configuration.bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: self.configuration.http2, + verificationCallback: nil + ) ) case .reloadingTLS(let certificateReloader): - let http2Config = NIOHTTP2Handler.Configuration( - httpServerHTTP2Configuration: configuration.http2 - ) - var tlsConfiguration: TLSConfiguration = try .makeServerConfiguration( certificateReloader: certificateReloader ) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - try await self.serveSecureUpgrade( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - handler: handler, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + return .secureUpgrade( + try await self.setupSecureUpgradeServerChannel( + bindTarget: self.configuration.bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: self.configuration.http2, + verificationCallback: nil + ) ) case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationMode, let verificationCallback): - let http2Config = NIOHTTP2Handler.Configuration( - httpServerHTTP2Configuration: configuration.http2 - ) - let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } let privateKey = try NIOSSLPrivateKeySource(privateKey) let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) @@ -232,20 +238,17 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - try await self.serveSecureUpgrade( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - handler: handler, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config, - verificationCallback: verificationCallback + return .secureUpgrade( + try await self.setupSecureUpgradeServerChannel( + bindTarget: self.configuration.bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: self.configuration.http2, + verificationCallback: verificationCallback + ) ) case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationMode, let verificationCallback): - let http2Config = NIOHTTP2Handler.Configuration( - httpServerHTTP2Configuration: configuration.http2 - ) - let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) var tlsConfiguration: TLSConfiguration = try .makeServerConfigurationWithMTLS( @@ -255,17 +258,31 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - try await self.serveSecureUpgrade( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - handler: handler, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config, - verificationCallback: verificationCallback + return .secureUpgrade( + try await self.setupSecureUpgradeServerChannel( + bindTarget: self.configuration.bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: self.configuration.http2, + verificationCallback: verificationCallback + ) ) } } + private func _serve( + serverChannel: ServerChannel, + handler: some HTTPServerRequestHandler + ) async throws { + switch serverChannel { + case .plaintextHTTP1(let http1Channel): + try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) + + case .secureUpgrade(let secureUpgradeChannel): + try await self.serveSecureUpgrade(serverChannel: secureUpgradeChannel, handler: handler) + } + } + func handleRequestChannel( channel: NIOAsyncChannel, handler: some HTTPServerRequestHandler @@ -345,6 +362,36 @@ public struct NIOHTTPServer: HTTPServer { throw error } } + + /// Fail the listening address promise if the server is shutting down before it began listening. + private func finishListeningAddressPromise() { + switch self.listeningAddressState.withLockedValue({ $0.close() }) { + case .failPromise(let promise, let error): + promise.fail(error) + + case .doNothing: + () + } + } + + /// Initiates a graceful shutdown, allowing existing connections to drain before closing. + private func beginGracefulShutdown() { + self.finishListeningAddressPromise() + self.serverQuiescingHelper.initiateShutdown(promise: nil) + } + + /// Forcefully closes the server channel without waiting for existing connections to drain. + private func close(serverChannel: ServerChannel) { + self.finishListeningAddressPromise() + + switch serverChannel { + case .plaintextHTTP1(let http1Channel): + http1Channel.channel.close(promise: nil) + + case .secureUpgrade(let secureUpgradeChannel): + secureUpgradeChannel.channel.close(promise: nil) + } + } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) diff --git a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift index 86a8e59..183a7f7 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift @@ -203,14 +203,42 @@ public struct NIOHTTPServerConfiguration: Sendable { /// The number of concurrent streams on the HTTP/2 connection. public var maxConcurrentStreams: Int? + /// The graceful shutdown configuration. + public var gracefulShutdown: GracefulShutdownConfiguration + + /// Configuration options for HTTP/2 graceful shutdown behavior. + public struct GracefulShutdownConfiguration: Sendable, Hashable { + /// The maximum amount of time that the connection has to close gracefully. + /// If set to `nil`, no time limit is enforced on the graceful shutdown process. + public var maximumGracefulShutdownDuration: Duration? + + /// Creates a graceful shutdown configuration with the specified timeout value. + /// + /// - Parameters: + /// - maximumGracefulShutdownDuration: The maximum amount of time that the connection has to close + /// gracefully. When `nil`, no time limit is enforced for active streams to finish during graceful + /// shutdown. + public init(maximumGracefulShutdownDuration: Duration? = nil) { + self.maximumGracefulShutdownDuration = maximumGracefulShutdownDuration + } + } + + /// - Parameters: + /// - maxFrameSize: The maximum frame size to be used in connections. + /// - targetWindowSize: The target window size for connections. This will also be set as the initial window + /// size. + /// - maxConcurrentStreams: The maximum number of concurrent streams permitted on connections. + /// - gracefulShutdown: The graceful shutdown configuration. public init( - maxFrameSize: Int, - targetWindowSize: Int, - maxConcurrentStreams: Int? + maxFrameSize: Int = Self.defaultMaxFrameSize, + targetWindowSize: Int = Self.defaultTargetWindowSize, + maxConcurrentStreams: Int? = Self.defaultMaxConcurrentStreams, + gracefulShutdown: GracefulShutdownConfiguration = .init() ) { self.maxFrameSize = maxFrameSize self.targetWindowSize = targetWindowSize self.maxConcurrentStreams = maxConcurrentStreams + self.gracefulShutdown = gracefulShutdown } @inlinable @@ -228,7 +256,8 @@ public struct NIOHTTPServerConfiguration: Sendable { Self( maxFrameSize: Self.defaultMaxFrameSize, targetWindowSize: Self.defaultTargetWindowSize, - maxConcurrentStreams: Self.defaultMaxConcurrentStreams + maxConcurrentStreams: Self.defaultMaxConcurrentStreams, + gracefulShutdown: GracefulShutdownConfiguration() ) } } diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift new file mode 100644 index 0000000..4fc7e04 --- /dev/null +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// 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 NIOHTTPTypes + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServer { + /// Abstracts over the two types of server channels ``NIOHTTPServer`` can create: plaintext HTTP/1.1 and Secure + /// Upgrade. + enum ServerChannel { + case plaintextHTTP1(NIOAsyncChannel, Never>) + case secureUpgrade(NIOAsyncChannel, Never>) + } +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift new file mode 100644 index 0000000..8e95e9e --- /dev/null +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -0,0 +1,271 @@ +//===----------------------------------------------------------------------===// +// +// 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 AsyncStreaming +import HTTPServer +import HTTPTypes +import Logging +import NIOCore +import NIOHTTPTypes +import NIOPosix +import ServiceLifecycle +import ServiceLifecycleTestKit +import Testing +import X509 + +@testable import NIOHTTPServer + +@Suite +struct NIOHTTPServiceLifecycleTests { + static let reqHead = HTTPRequestPart.head(.init(method: .post, scheme: "http", authority: "", path: "/")) + static let bodyData = ByteBuffer(repeating: 5, count: 100) + static let reqBody = HTTPRequestPart.body(Self.bodyData) + static let trailer: HTTPFields = [.trailer: "test_trailer"] + static let reqEnd = HTTPRequestPart.end(trailer) + + let serverLogger = Logger(label: "Test Server") + let serviceGroupLogger = Logger(label: "Test ServiceGroup") + + @Test("HTTP/1.1 active connection completes when graceful shutdown triggered", ) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func activeHTTP1ConnectionCanCompleteWhenGracefulShutdown() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + ) + + // This promise will be fulfilled when the server receives the first part of the body. Once this happens, we can + // initiate the graceful shutdown and then send the remaining body. If graceful shutdown is respected, we should + // be able to successfully complete the request. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) + + let serverService = ClosureService { + try await server.serve { request, requestContext, requestReader, responseSender in + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + + firstChunkReadPromise.succeed() + + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + } + + let responseBodyWriter = try await responseSender.send(.init(status: .ok)) + try await responseBodyWriter.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return .none + } + } + } + + try await confirmation { responseReceived in + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) + group.addTask { try await serviceGroup.run() } + + let serverAddress = try await server.listeningAddress + + let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + + // Write the first body part. + try await outbound.write(Self.reqBody) + + // Wait until the server has received the first body part. + try await firstChunkReadPromise.futureResult.get() + + // Start the shutdown. + trigger.triggerGracefulShutdown() + + // We should be able to complete our request. + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + for try await response in inbound { + switch response { + case .head(let head): + #expect(head.status == .ok) + case .body(let body): + #expect(body == .init([1, 2])) + case .end(let trailers): + #expect(trailers == nil) + } + } + + responseReceived() + + // The server should now shut down. Wait for this. + try await group.waitForAll() + } + } + } + } + } + + @Test("HTTP/1.1 active connection forcefully shutdown when server task cancelled") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func activeHTTP1ConnectionForcefullyShutdownWhenServerTaskCancelled() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + ) + + // This promise will be fulfilled when the server receives the first part of the request body. Once this + // happens, we cancel the server task and test whether the in-flight request's connection was forcefully shut. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) + + let serverService = ClosureService { + await #expect(throws: CancellationError.self) { + try await server.serve { request, requestContext, requestReader, responseSender in + // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + let error = try await #require(throws: EitherError.self) { + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + + firstChunkReadPromise.succeed() + + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive. + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + } + #expect(throws: CancellationError.self) { try error.unwrap() } + } + } + } + } + + try await confirmation { connectionForcefullyClosed in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) + group.addTask { try await serviceGroup.run() } + + let serverAddress = try await server.listeningAddress + + let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + + // Write the first body part. + try await outbound.write(Self.reqBody) + + // Wait until the server has received the first body part. + try await firstChunkReadPromise.futureResult.get() + + // Cancel the server task. + group.cancelAll() + // Wait for the server to shut down. + try await group.waitForAll() + + // We shouldn't be able to complete our request; the server should have shut down. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(Self.reqBody) + } + + connectionForcefullyClosed() + } + } + } + } + + @Test("Active HTTP/2 connection is forcefully shut down upon graceful shutdown timeout") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testActiveHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { + let serverChain = try TestCA.makeSelfSignedChain() + let clientChain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + transportSecurity: .tls(certificateChain: serverChain.chain, privateKey: serverChain.privateKey), + http2: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500))) + ) + ) + + // This promise will be fulfilled when the server receives the first part of the request body. Once this + // happens, we can initiate the graceful shutdown. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) + + let serverService = ClosureService { + try await server.serve { request, requestContext, requestReader, responseSender in + // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + let error = try await #require(throws: EitherError.self) { + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + + firstChunkReadPromise.succeed() + + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive until the grace timer (500ms) fires. + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + } + #expect(throws: RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) { try error.unwrap() } + } + } + } + + try await confirmation { connectionForcefullyShutdown in + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) + group.addTask { try await serviceGroup.run() } + + let serverAddress = try await server.listeningAddress + + let client = try await setUpClientWithMTLS( + at: serverAddress, + chain: clientChain, + trustRoots: [serverChain.ca], + applicationProtocol: "h2" + ) + + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + + // Wait until the server has received the request. + try await firstChunkReadPromise.futureResult.get() + + // Now trigger graceful shutdown. This should propagate down to the server. The server will + // start the 500ms grace timer after which all connections that are still open will be + // forcefully closed. + trigger.triggerGracefulShutdown() + + // The server should shut down after 500ms. Wait for this. + try await group.waitForAll() + + // The connection should have been closed: we should get an `ioOnClosedChannel` error. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(Self.reqEnd) + } + + connectionForcefullyShutdown() + } + } + } + } + } +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index fd44f83..f5635d8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -141,13 +141,17 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize) #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) #expect(http2.maxConcurrentStreams == nil) + #expect(http2.gracefulShutdown == .init(maximumGracefulShutdownDuration: nil)) } @Test("Custom values") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testCustomValues() throws { let provider = InMemoryProvider(values: [ - "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, + "maxFrameSize": 1, + "targetWindowSize": 2, + "maxConcurrentStreams": 3, + "maximumGracefulShutdownDuration": 4, ]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -157,6 +161,7 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == 1) #expect(http2.targetWindowSize == 2) #expect(http2.maxConcurrentStreams == 3) + #expect(http2.gracefulShutdown.maximumGracefulShutdownDuration == .seconds(4)) } @Test("Partial custom values") @@ -171,6 +176,7 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == 5) #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) #expect(http2.maxConcurrentStreams == nil) + #expect(http2.gracefulShutdown.maximumGracefulShutdownDuration == nil) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift b/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift index 447546e..4b0e844 100644 --- a/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift +++ b/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift @@ -32,12 +32,12 @@ struct HTTPSecureUpgradeClientServerProvider { let serverTLSConfiguration: TLSConfiguration let verificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? - let http2Configuration: NIOHTTP2Handler.Configuration + let http2Configuration: NIOHTTPServerConfiguration.HTTP2 static func withProvider( tlsConfiguration: TLSConfiguration, tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? = nil, - http2Configuration: NIOHTTP2Handler.Configuration = .init(), + http2Configuration: NIOHTTPServerConfiguration.HTTP2 = .defaults, handler: some HTTPServerRequestHandler, body: (HTTPSecureUpgradeClientServerProvider) async throws -> Void ) async throws { diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift index dbab490..96b78d8 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+HTTP1.swift @@ -39,6 +39,6 @@ extension NIOHTTPServer { try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) _ = try await self.listeningAddress - try await _serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) + try await self.serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift index d8955ad..2c6288b 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOHTTPServer+SecureUpgrade.swift @@ -39,6 +39,6 @@ extension NIOHTTPServer { try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) _ = try await self.listeningAddress - try await self._serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) + try await self.serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) } }