From 4c6a241f57d8578fc0de17bbf399054a82a07c27 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 6 Feb 2026 11:41:20 +0000 Subject: [PATCH 01/26] Add support for swift-service-lifecycle --- Package.swift | 21 +- .../HTTPGracefulShutdown.swift | 18 ++ ...verClosureRequestHandler+HTTPService.swift | 48 +++++ .../ServiceLifecycle/HTTPService.swift | 61 ++++++ .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 13 ++ .../NIOHTTPServer+SecureUpgrade.swift | 79 +++++--- .../NIOHTTPServer+ServiceLifecycle.swift | 29 +++ .../NIOHTTPServer+SwiftConfiguration.swift | 21 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 54 +++--- .../NIOHTTPServerConfiguration.swift | 37 +++- .../NIOHTTPServer+ServiceLifecycleTests.swift | 181 ++++++++++++++++++ ...TTPSecureUpgradeClientServerProvider.swift | 4 +- 12 files changed, 502 insertions(+), 64 deletions(-) create mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift create mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift create mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPService.swift create mode 100644 Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift create mode 100644 Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift diff --git a/Package.swift b/Package.swift index f71a28d..7ec997c 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,8 @@ let package = Package( ], traits: [ .trait(name: "SwiftConfiguration"), - .default(enabledTraits: ["SwiftConfiguration"]), + .trait(name: "ServiceLifecycle"), + .default(enabledTraits: ["SwiftConfiguration", "ServiceLifecycle"]), ], dependencies: [ .package( @@ -55,8 +56,10 @@ 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"), + // TODO: Update dependency once PR is merged. + .package(url: "https://github.com/aryan-25/swift-nio-http2.git", branch: "server-connection-manager"), + .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.9.1"), ], targets: [ .executableTarget( @@ -78,6 +81,11 @@ let package = Package( dependencies: [ "AsyncStreaming", .product(name: "HTTPTypes", package: "swift-http-types"), + .product( + name: "ServiceLifecycle", + package: "swift-service-lifecycle", + condition: .when(traits: ["ServiceLifecycle"]) + ), ], swiftSettings: extraSettings ), @@ -103,6 +111,11 @@ let package = Package( package: "swift-configuration", condition: .when(traits: ["SwiftConfiguration"]) ), + .product( + name: "NIOExtras", + package: "swift-nio-extras", + condition: .when(traits: ["ServiceLifecycle"]) + ), "HTTPServer", ], swiftSettings: extraSettings @@ -126,6 +139,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/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift new file mode 100644 index 0000000..26dd169 --- /dev/null +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift @@ -0,0 +1,18 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// A protocol for HTTP servers that support graceful shutdown. +public protocol GracefulShutdownService { + /// Initiates graceful shutdown of the HTTP server. + func beginGracefulShutdown() +} diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift new file mode 100644 index 0000000..15dbc9b --- /dev/null +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if ServiceLifecycle +public import AsyncStreaming +public import HTTPTypes + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension HTTPService +where + Handler == HTTPServerClosureRequestHandler< + Server.RequestReader, + Server.RequestReader.Underlying, + Server.ResponseWriter, + Server.ResponseWriter.Underlying + > +{ + /// - Parameters: + /// - server: The underlying HTTPServer instance. + /// - serverHandler: The request handler closure. + /// - gracefulShutdownHandler: A closure to execute upon graceful shutdown. + public init( + server: Server, + serverHandler: + nonisolated(nonsending) @Sendable @escaping ( + _ request: HTTPRequest, + _ requestContext: HTTPRequestContext, + _ requestBodyAndTrailers: consuming sending Server.RequestReader, + _ responseSender: consuming sending HTTPResponseSender + ) async throws -> Void, + gracefulShutdownHandler: @Sendable @escaping () -> Void = {} + ) { + self.server = server + self.serverHandler = HTTPServerClosureRequestHandler(handler: serverHandler) + self.gracefulShutdownHandler = gracefulShutdownHandler + } +} +#endif // ServiceLifecycle diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift new file mode 100644 index 0000000..464256c --- /dev/null +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPService.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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if ServiceLifecycle +public import ServiceLifecycle + +/// A wrapper over HTTPServer that integrates with ServiceLifecycle. +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +public struct HTTPService< + Server: HTTPServer & GracefulShutdownService, + Handler: HTTPServerRequestHandler +>: Service +where + Server.RequestReader == Handler.RequestReader, + Server.ResponseWriter == Handler.ResponseWriter +{ + let server: Server + let serverHandler: Handler + let gracefulShutdownHandler: @Sendable () -> Void + + /// - Parameters: + /// - server: The underlying HTTPServer instance. + /// - serverHandler: The request handler that `server` will use. + /// - onGracefulShutdown: A closure to execute upon graceful shutdown. + /// + /// - Note: The `onGracefulShutdown` closure will be called *after* initiating graceful shutdown on `server`. + public init( + server: Server, + serverHandler: Handler, + onGracefulShutdown gracefulShutdownHandler: @Sendable @escaping () -> Void = {} + ) { + self.server = server + self.serverHandler = serverHandler + self.gracefulShutdownHandler = gracefulShutdownHandler + } + + /// Runs the HTTP server and handles graceful shutdown when signaled. + public func run() async throws { + try await withGracefulShutdownHandler( + operation: { + try await self.server.serve(handler: self.serverHandler) + }, + onGracefulShutdown: { + self.server.beginGracefulShutdown() + // Call the user-provided graceful shutdown handler + self.gracefulShutdownHandler() + } + ) + } +} +#endif // ServiceLifecycle diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index dc5cd13..2c0ea03 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -20,6 +20,10 @@ import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix +#if ServiceLifecycle +import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. +#endif + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTPServer { func serveInsecureHTTP1_1( @@ -43,6 +47,15 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + #if ServiceLifecycle + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + } + } + #endif // ServiceLifecycle .bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( channel: channel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 4315ede..af025c8 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -24,6 +24,11 @@ import NIOPosix import NIOSSL import X509 +#if ServiceLifecycle +import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. +import NIOHTTP1 +#endif + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTPServer { func serveSecureUpgrade( @@ -31,7 +36,7 @@ extension NIOHTTPServer { tlsConfiguration: TLSConfiguration, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, + http2Configuration: NIOHTTPServerConfiguration.HTTP2, verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil ) async throws { let serverChannel = try await self.setupSecureUpgradeServerChannel( @@ -54,13 +59,22 @@ extension NIOHTTPServer { bindTarget: NIOHTTPServerConfiguration.BindTarget, tlsConfiguration: TLSConfiguration, asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.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) + #if ServiceLifecycle + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) + ) + } + } + #endif // ServiceLifecycle .bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, @@ -81,7 +95,7 @@ extension NIOHTTPServer { channel: any Channel, tlsConfiguration: TLSConfiguration, asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, + http2Configuration: NIOHTTPServerConfiguration.HTTP2, verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? ) -> EventLoopFuture> { channel.eventLoop.makeCompletedFuture { @@ -89,31 +103,48 @@ extension NIOHTTPServer { 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 - ) + 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: { 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 + http2ConnectionInitializer: { http2Channel in + http2Channel.eventLoop.makeCompletedFuture { + try http2Channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + mode: .server, + connectionManagerConfiguration: .init( + maxIdleTime: nil, + maxAge: nil, + maxGraceTime: http2Configuration.gracefulShutdown.maxGraceTime, + 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))) + } } ) } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift new file mode 100644 index 0000000..282488c --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if ServiceLifecycle +public import HTTPServer +import HTTPTypes +import Logging +import NIOExtras + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer: GracefulShutdownService { + /// Initiates graceful shutdown of the HTTP server. + public func beginGracefulShutdown() { + self.close() + self.serverQuiescingHelper.initiateShutdown(promise: nil) + } +} +#endif // ServiceLifecycle diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift index 95b1c61..5da16da 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift @@ -14,7 +14,9 @@ #if SwiftConfiguration 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,11 +302,26 @@ 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.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { + /// Initialize a HTTP/2 graceful shutdown configuration from a config reader. + /// + /// ## Configuration keys: + /// - `maxGraceTimeSeconds` (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(maxGraceTime: config.int(forKey: "maxGraceTimeSeconds").map { .seconds(Int64($0)) }) + } +} + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTPServerConfiguration.TransportSecurity { fileprivate enum TransportSecurityKind: String { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index e5d3083..0ae7255 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -29,6 +29,10 @@ import SwiftASN1 import Synchronization import X509 +#if ServiceLifecycle +import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. +#endif + /// A generic HTTP server that can handle incoming HTTP requests. /// /// The `Server` class provides a high-level interface for creating HTTP servers with support for: @@ -85,6 +89,10 @@ public struct NIOHTTPServer: HTTPServer { let logger: Logger private let configuration: NIOHTTPServerConfiguration + #if ServiceLifecycle + let serverQuiescingHelper: ServerQuiescingHelper + #endif + var listeningAddressState: NIOLockedValueBox /// Task-local storage for connection-specific information accessible from request handlers. @@ -106,6 +114,10 @@ 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())) + + #if ServiceLifecycle + self.serverQuiescingHelper = .init(group: eventLoopGroup) + #endif } /// Starts an HTTP server with the specified request handler. @@ -147,14 +159,7 @@ 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: - () - } - } + defer { self.close() } let asyncChannelConfiguration: NIOAsyncChannel.Configuration switch self.configuration.backpressureStrategy.backing { @@ -174,10 +179,6 @@ public struct NIOHTTPServer: HTTPServer { ) 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) @@ -192,14 +193,10 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: self.configuration.http2 ) case .reloadingTLS(let certificateReloader): - let http2Config = NIOHTTP2Handler.Configuration( - httpServerHTTP2Configuration: configuration.http2 - ) - var tlsConfiguration: TLSConfiguration = try .makeServerConfiguration( certificateReloader: certificateReloader ) @@ -210,14 +207,10 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: self.configuration.http2 ) 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) @@ -235,15 +228,11 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config, + 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( @@ -258,7 +247,7 @@ public struct NIOHTTPServer: HTTPServer { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config, + http2Configuration: self.configuration.http2, verificationCallback: verificationCallback ) } @@ -343,6 +332,15 @@ public struct NIOHTTPServer: HTTPServer { throw error } } + + func close() { + switch self.listeningAddressState.withLockedValue({ $0.close() }) { + case .failPromise(let promise, let error): + promise.fail(error) + case .doNothing: + () + } + } } @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) diff --git a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift index 43d5274..46b2a62 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// public import NIOCertificateReloading -import NIOCore +public import NIOCore import NIOSSL public import X509 @@ -203,14 +203,40 @@ 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 maxGraceTime: TimeAmount? + + /// Creates a graceful shutdown configuration with the specified timeout value. + /// - Parameters: + /// - maxGraceTime: 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(maxGraceTime: TimeAmount? = nil) { + self.maxGraceTime = maxGraceTime + } + } + + /// - 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 +254,8 @@ public struct NIOHTTPServerConfiguration: Sendable { Self( maxFrameSize: Self.defaultMaxFrameSize, targetWindowSize: Self.defaultTargetWindowSize, - maxConcurrentStreams: Self.defaultMaxConcurrentStreams + maxConcurrentStreams: Self.defaultMaxConcurrentStreams, + gracefulShutdown: GracefulShutdownConfiguration() ) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift new file mode 100644 index 0000000..11db452 --- /dev/null +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// 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 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) + + @Test("HTTP/1.1 in-flight request during graceful shutdown") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testHTTP1ConnectionInFlightRequestCompletesDuringGracefulShutdown() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + ) + + // Create a promise that will be fulfilled when the server receives the request. When this promise is fulfilled, + // we can initiate the graceful shutdown. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let requestReceivedPromise = elg.any().makePromise(of: Void.self) + + let serverService = HTTPService(server: server) { request, requestContext, reader, responseWriter in + requestReceivedPromise.succeed() + + // The server is expecting 2 `Self.bodyData` parts. After the client sends the first body part, graceful + // shutdown is triggered. The client should be able to send the second body part and complete the inflight + // request before the server shuts down. + _ = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + try await bodyReader.collect(upTo: Self.bodyData.readableBytes * 2) { _ in } + } + + let responseBodyWriter = try await responseWriter.send(.init(status: .ok)) + try await responseBodyWriter.produceAndConclude { + (writer: consuming HTTPResponseConcludingAsyncWriter.ResponseBodyAsyncWriter) in + try await writer.write([1, 2].span) + return .none + } + } + + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + 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 request. + try await requestReceivedPromise.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) + } + } + + // The server should now shut down. Wait for this. + try await group.waitForAll() + } + } + } + } + + @Test("Long-running HTTP/2 connection is forcefully shut down upon graceful shutdown timeout") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testLongRunningHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { + let serverChain = try TestCA.makeSelfSignedChain() + let clientChain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + transportSecurity: .tls( + certificateChain: serverChain.chain, + privateKey: serverChain.privateKey + ), + http2: .init(gracefulShutdown: .init(maxGraceTime: .milliseconds(500))) + ) + ) + + // Create a promise that will be fulfilled when the server receives the request. When this promise is fulfilled, + // we can initiate the graceful shutdown. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let requestReceivedPromise = elg.any().makePromise(of: Void.self) + + let serverService = HTTPService(server: server) { request, requestContext, reader, responseWriter in + requestReceivedPromise.succeed() + + // Consume the body: this will block because 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 reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + try await bodyReader.collect(upTo: 100) { _ in } + } + } + + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + 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 requestReceivedPromise.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) + } + } + } + } + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift b/Tests/NIOHTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift index 41cab29..86c9a3c 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 { From 4f29521b590f2da32838f8d33242b271b983d153 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 09:53:45 +0000 Subject: [PATCH 02/26] Fix license headers --- Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift | 1 + .../HTTPServerClosureRequestHandler+HTTPService.swift | 1 + Sources/HTTPServer/ServiceLifecycle/HTTPService.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift index 26dd169..f930386 100644 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift @@ -6,6 +6,7 @@ // 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 // diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift index 15dbc9b..978eff7 100644 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift @@ -6,6 +6,7 @@ // 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 // diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift index 464256c..e6a3cf3 100644 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift @@ -6,6 +6,7 @@ // 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 // From 126ca49f88bdc7c7ff303d08d1485cf0851e9022 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 10:33:32 +0000 Subject: [PATCH 03/26] Update tests --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 8 ++++++-- .../NIOHTTPServerSwiftConfigurationTests.swift | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 11db452..733cca1 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import AsyncStreaming import HTTPServer import HTTPTypes import Logging @@ -33,7 +34,7 @@ struct NIOHTTPServiceLifecycleTests { static let trailer: HTTPFields = [.trailer: "test_trailer"] static let reqEnd = HTTPRequestPart.end(trailer) - @Test("HTTP/1.1 in-flight request during graceful shutdown") + @Test("HTTP/1.1 in-flight request completes after graceful shutdown triggered") @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) func testHTTP1ConnectionInFlightRequestCompletesDuringGracefulShutdown() async throws { let server = NIOHTTPServer( @@ -138,7 +139,10 @@ struct NIOHTTPServiceLifecycleTests { // intentional because we want to keep the connection alive until the grace timer (500ms) fires. _ = try await reader.consumeAndConclude { bodyReader in var bodyReader = bodyReader - try await bodyReader.collect(upTo: 100) { _ in } + let error = try await #require(throws: EitherError.self) { + try await bodyReader.collect(upTo: 100) { _ in } + } + #expect(throws: RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) { try error.unwrap() } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 8391af2..651d5f7 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(maxGraceTime: nil)) } @Test("Custom values") @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) func testCustomValues() throws { let provider = InMemoryProvider(values: [ - "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, + "maxFrameSize": 1, + "targetWindowSize": 2, + "maxConcurrentStreams": 3, + "maxGraceTimeSeconds": 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.maxGraceTime == .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.maxGraceTime == nil) } } From 799ae5fe90b1657670d41da3c49257971d47d657 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 10:33:58 +0000 Subject: [PATCH 04/26] Remove `onGracefulShutdown` argument --- .../HTTPServerClosureRequestHandler+HTTPService.swift | 5 +---- Sources/HTTPServer/ServiceLifecycle/HTTPService.swift | 7 ------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift index 978eff7..f733d35 100644 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift @@ -29,7 +29,6 @@ where /// - Parameters: /// - server: The underlying HTTPServer instance. /// - serverHandler: The request handler closure. - /// - gracefulShutdownHandler: A closure to execute upon graceful shutdown. public init( server: Server, serverHandler: @@ -38,12 +37,10 @@ where _ requestContext: HTTPRequestContext, _ requestBodyAndTrailers: consuming sending Server.RequestReader, _ responseSender: consuming sending HTTPResponseSender - ) async throws -> Void, - gracefulShutdownHandler: @Sendable @escaping () -> Void = {} + ) async throws -> Void ) { self.server = server self.serverHandler = HTTPServerClosureRequestHandler(handler: serverHandler) - self.gracefulShutdownHandler = gracefulShutdownHandler } } #endif // ServiceLifecycle diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift index e6a3cf3..2fab91f 100644 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift +++ b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift @@ -27,14 +27,10 @@ where { let server: Server let serverHandler: Handler - let gracefulShutdownHandler: @Sendable () -> Void /// - Parameters: /// - server: The underlying HTTPServer instance. /// - serverHandler: The request handler that `server` will use. - /// - onGracefulShutdown: A closure to execute upon graceful shutdown. - /// - /// - Note: The `onGracefulShutdown` closure will be called *after* initiating graceful shutdown on `server`. public init( server: Server, serverHandler: Handler, @@ -42,7 +38,6 @@ where ) { self.server = server self.serverHandler = serverHandler - self.gracefulShutdownHandler = gracefulShutdownHandler } /// Runs the HTTP server and handles graceful shutdown when signaled. @@ -53,8 +48,6 @@ where }, onGracefulShutdown: { self.server.beginGracefulShutdown() - // Call the user-provided graceful shutdown handler - self.gracefulShutdownHandler() } ) } From d64751ba591cf1f265ce47bfdc578bd085570d7a Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 10:34:41 +0000 Subject: [PATCH 05/26] Formatting --- Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 6 ++++-- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 6 ++++-- .../NIOHTTPServerSwiftConfigurationTests.swift | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 2c0ea03..212edca 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -47,15 +47,17 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - #if ServiceLifecycle .serverChannelInitializer { channel in + #if ServiceLifecycle channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } + #else + channel.eventLoop.makeSucceededVoidFuture() + #endif // ServiceLifecycle } - #endif // ServiceLifecycle .bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( channel: channel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index af025c8..07744c6 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -66,15 +66,17 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - #if ServiceLifecycle .serverChannelInitializer { channel in + #if ServiceLifecycle channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } + #else + channel.eventLoop.makeSucceededVoidFuture() + #endif // ServiceLifecycle } - #endif // ServiceLifecycle .bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 651d5f7..6b437fc 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -151,7 +151,7 @@ struct NIOHTTPServerSwiftConfigurationTests { "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, - "maxGraceTimeSeconds": 4 + "maxGraceTimeSeconds": 4, ]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() From 54aa03b6b71f187e7a6418227d60efa7d81499f2 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 14:41:30 +0000 Subject: [PATCH 06/26] Enclose ServiceLifecycle tests with `#if` trait guard --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 733cca1..1b613b9 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +#if ServiceLifecycle import AsyncStreaming import HTTPServer import HTTPTypes @@ -183,3 +184,4 @@ struct NIOHTTPServiceLifecycleTests { } } } +#endif // ServiceLifecycle From c07e575e23159c6cc33083208ad98267015724ba Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 14:48:30 +0000 Subject: [PATCH 07/26] Move `NIOHTTP1` import outside ServiceLifecycle guard The `configureHTTPServerPipeline` helper used in the `setupSecureUpgradeConnectionChildChannel` method originates from `NIOHTTP1`. --- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 07744c6..fdb5d67 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -16,6 +16,7 @@ import HTTPServer import Logging import NIOCore import NIOEmbedded +import NIOHTTP1 import NIOHTTP2 import NIOHTTPTypes import NIOHTTPTypesHTTP1 @@ -26,7 +27,6 @@ import X509 #if ServiceLifecycle import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. -import NIOHTTP1 #endif @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) From 2eb4ec8780f0c430f94bc01b7fc7d04f0fabadd4 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 15:08:50 +0000 Subject: [PATCH 08/26] Disable `ServiceLifecycle` trait by default --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 7ec997c..6f976d4 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,7 @@ let package = Package( traits: [ .trait(name: "SwiftConfiguration"), .trait(name: "ServiceLifecycle"), - .default(enabledTraits: ["SwiftConfiguration", "ServiceLifecycle"]), + .default(enabledTraits: ["SwiftConfiguration"]), ], dependencies: [ .package( From 4c4b00bc15647d94122a727e3ca4599411133216 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 15:10:04 +0000 Subject: [PATCH 09/26] Add section for traits in README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index d85ac67..32c44bf 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,23 @@ 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). +To enable an additional trait on the package, update the package dependency: + +```diff +.package( + url: "https://github.com/swift-server/swift-http-server.git", + from: "...", ++ traits: [.defaults, "ServiceLifecycle"] +) +``` + +Available traits: +- **`SwiftConfiguration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` + `ConfigProvider`. +- **`ServiceLifecycle`** (opt-in): Enables `HTTPService`, which allows the server to be run with `ServiceGroup` from + `swift-service-lifecycle`, including support for graceful shutdown. From a6e41d2a1ffacaef5f80b731523d843f2fea2e42 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 15:12:09 +0000 Subject: [PATCH 10/26] Formatting --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 1b613b9..f6672f7 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -184,4 +184,4 @@ struct NIOHTTPServiceLifecycleTests { } } } -#endif // ServiceLifecycle +#endif // ServiceLifecycle From 94ea5d245bfe6a6b32300e6343f9662cbbe1e2c2 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 16 Feb 2026 22:39:36 +0000 Subject: [PATCH 11/26] Guard ServiceLifecycle dependency in test target behind condition --- Package.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 6f976d4..fc1b797 100644 --- a/Package.swift +++ b/Package.swift @@ -139,8 +139,16 @@ 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"), + .product( + name: "ServiceLifecycle", + package: "swift-service-lifecycle", + condition: .when(traits: ["ServiceLifecycle"]) + ), + .product( + name: "ServiceLifecycleTestKit", + package: "swift-service-lifecycle", + condition: .when(traits: ["ServiceLifecycle"]) + ), "NIOHTTPServer", ] ), From 29958fef4665953d9072d88d4082561cbbcaf08b Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 18 Feb 2026 15:41:53 +0000 Subject: [PATCH 12/26] Remove changes to abstract `HTTPServer` API --- .../HTTPGracefulShutdown.swift | 19 ------- ...verClosureRequestHandler+HTTPService.swift | 46 ---------------- .../ServiceLifecycle/HTTPService.swift | 55 ------------------- 3 files changed, 120 deletions(-) delete mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift delete mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift delete mode 100644 Sources/HTTPServer/ServiceLifecycle/HTTPService.swift diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift deleted file mode 100644 index f930386..0000000 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPGracefulShutdown.swift +++ /dev/null @@ -1,19 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 HTTP servers that support graceful shutdown. -public protocol GracefulShutdownService { - /// Initiates graceful shutdown of the HTTP server. - func beginGracefulShutdown() -} diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift deleted file mode 100644 index f733d35..0000000 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPServerClosureRequestHandler+HTTPService.swift +++ /dev/null @@ -1,46 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// - -#if ServiceLifecycle -public import AsyncStreaming -public import HTTPTypes - -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -extension HTTPService -where - Handler == HTTPServerClosureRequestHandler< - Server.RequestReader, - Server.RequestReader.Underlying, - Server.ResponseWriter, - Server.ResponseWriter.Underlying - > -{ - /// - Parameters: - /// - server: The underlying HTTPServer instance. - /// - serverHandler: The request handler closure. - public init( - server: Server, - serverHandler: - nonisolated(nonsending) @Sendable @escaping ( - _ request: HTTPRequest, - _ requestContext: HTTPRequestContext, - _ requestBodyAndTrailers: consuming sending Server.RequestReader, - _ responseSender: consuming sending HTTPResponseSender - ) async throws -> Void - ) { - self.server = server - self.serverHandler = HTTPServerClosureRequestHandler(handler: serverHandler) - } -} -#endif // ServiceLifecycle diff --git a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift b/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift deleted file mode 100644 index 2fab91f..0000000 --- a/Sources/HTTPServer/ServiceLifecycle/HTTPService.swift +++ /dev/null @@ -1,55 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// - -#if ServiceLifecycle -public import ServiceLifecycle - -/// A wrapper over HTTPServer that integrates with ServiceLifecycle. -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -public struct HTTPService< - Server: HTTPServer & GracefulShutdownService, - Handler: HTTPServerRequestHandler ->: Service -where - Server.RequestReader == Handler.RequestReader, - Server.ResponseWriter == Handler.ResponseWriter -{ - let server: Server - let serverHandler: Handler - - /// - Parameters: - /// - server: The underlying HTTPServer instance. - /// - serverHandler: The request handler that `server` will use. - public init( - server: Server, - serverHandler: Handler, - onGracefulShutdown gracefulShutdownHandler: @Sendable @escaping () -> Void = {} - ) { - self.server = server - self.serverHandler = serverHandler - } - - /// Runs the HTTP server and handles graceful shutdown when signaled. - public func run() async throws { - try await withGracefulShutdownHandler( - operation: { - try await self.server.serve(handler: self.serverHandler) - }, - onGracefulShutdown: { - self.server.beginGracefulShutdown() - } - ) - } -} -#endif // ServiceLifecycle From 53b0a011dd7fcbfc6ecf86aa29ea98290be81ef4 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 18 Feb 2026 16:17:15 +0000 Subject: [PATCH 13/26] Update availability --- Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift | 2 +- Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift | 4 ++-- .../NIOHTTPServer+ServiceLifecycleTests.swift | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift index 282488c..433fb38 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift @@ -18,7 +18,7 @@ import HTTPTypes import Logging import NIOExtras -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer: GracefulShutdownService { /// Initiates graceful shutdown of the HTTP server. public func beginGracefulShutdown() { diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift index 2b228bb..5533929 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift @@ -308,7 +308,7 @@ extension NIOHTTPServerConfiguration.HTTP2 { } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@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. /// @@ -322,7 +322,7 @@ extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServerConfiguration.TransportSecurity { fileprivate enum TransportSecurityKind: String { case plaintext diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index f6672f7..ec068ae 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -36,7 +36,7 @@ struct NIOHTTPServiceLifecycleTests { static let reqEnd = HTTPRequestPart.end(trailer) @Test("HTTP/1.1 in-flight request completes after graceful shutdown triggered") - @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testHTTP1ConnectionInFlightRequestCompletesDuringGracefulShutdown() async throws { let server = NIOHTTPServer( logger: Logger(label: "Test"), @@ -111,7 +111,7 @@ struct NIOHTTPServiceLifecycleTests { } @Test("Long-running HTTP/2 connection is forcefully shut down upon graceful shutdown timeout") - @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testLongRunningHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { let serverChain = try TestCA.makeSelfSignedChain() let clientChain = try TestCA.makeSelfSignedChain() From ea5a2da954eaa349403ffa89d7f02619b1bd08b3 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 19 Feb 2026 10:08:52 +0000 Subject: [PATCH 14/26] Update README with new trait name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32c44bf..39e07d3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ To enable an additional trait on the package, update the package dependency: ``` Available traits: -- **`SwiftConfiguration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` +- **`Configuration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` `ConfigProvider`. - **`ServiceLifecycle`** (opt-in): Enables `HTTPService`, which allows the server to be run with `ServiceGroup` from `swift-service-lifecycle`, including support for graceful shutdown. From f6d076a5f22bb12d74e1ccd6e75b7bf1336620fb Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 19 Feb 2026 10:09:40 +0000 Subject: [PATCH 15/26] Update NIOHTTP2 dependency --- Package.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 48cd567..13d7d80 100644 --- a/Package.swift +++ b/Package.swift @@ -53,8 +53,7 @@ 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"), - // TODO: Update dependency once PR is merged. - .package(url: "https://github.com/aryan-25/swift-nio-http2.git", branch: "server-connection-manager"), + .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.9.1"), ], From 321af081238b59289a3b866b6b8acc3515c2e858 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 19 Feb 2026 10:12:53 +0000 Subject: [PATCH 16/26] Update HTTPServer dependency --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 13d7d80..8f178e9 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,8 @@ let package = Package( .default(enabledTraits: ["Configuration"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-http-api-proposal", branch: "main"), + // TODO: Update once PR #106 is merged. + .package(url: "https://github.com/aryan-25/swift-http-api-proposal.git", branch: "http-server-service-lifecycle"), .package( url: "https://github.com/FranzBusch/swift-collections.git", branch: "fb-async" From 2d57b9a879bd093ff475814621e19e59e6e341d6 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 19 Feb 2026 10:39:21 +0000 Subject: [PATCH 17/26] Formatting --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8f178e9..28f14d2 100644 --- a/Package.swift +++ b/Package.swift @@ -42,7 +42,10 @@ let package = Package( ], dependencies: [ // TODO: Update once PR #106 is merged. - .package(url: "https://github.com/aryan-25/swift-http-api-proposal.git", branch: "http-server-service-lifecycle"), + .package( + url: "https://github.com/aryan-25/swift-http-api-proposal.git", + branch: "http-server-service-lifecycle" + ), .package( url: "https://github.com/FranzBusch/swift-collections.git", branch: "fb-async" From 17a0587bedc561b33ed6e06c4396fe923050f011 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 12:45:54 +0000 Subject: [PATCH 18/26] Remove `GracefulShutdownService` conformance --- .../NIOHTTPServer+ServiceLifecycle.swift | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift deleted file mode 100644 index 433fb38..0000000 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ServiceLifecycle.swift +++ /dev/null @@ -1,29 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// - -#if ServiceLifecycle -public import HTTPServer -import HTTPTypes -import Logging -import NIOExtras - -@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension NIOHTTPServer: GracefulShutdownService { - /// Initiates graceful shutdown of the HTTP server. - public func beginGracefulShutdown() { - self.close() - self.serverQuiescingHelper.initiateShutdown(promise: nil) - } -} -#endif // ServiceLifecycle From 05a003ec34a44ff84a16d6dd92ea0cb0c0e06f1c Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 12:51:31 +0000 Subject: [PATCH 19/26] Remove `ServiceLifecycle` trait; depend on `swift-service-lifecycle` by default. --- Package.swift | 25 +++---------------- README.md | 11 -------- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 9 +------ .../NIOHTTPServer+SecureUpgrade.swift | 9 +------ Sources/NIOHTTPServer/NIOHTTPServer.swift | 10 ++------ .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 -- 6 files changed, 8 insertions(+), 58 deletions(-) diff --git a/Package.swift b/Package.swift index 28f14d2..52a1594 100644 --- a/Package.swift +++ b/Package.swift @@ -37,15 +37,10 @@ let package = Package( ], traits: [ .trait(name: "Configuration"), - .trait(name: "ServiceLifecycle"), .default(enabledTraits: ["Configuration"]), ], dependencies: [ - // TODO: Update once PR #106 is merged. - .package( - url: "https://github.com/aryan-25/swift-http-api-proposal.git", - branch: "http-server-service-lifecycle" - ), + .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" @@ -97,11 +92,7 @@ let package = Package( package: "swift-configuration", condition: .when(traits: ["Configuration"]) ), - .product( - name: "NIOExtras", - package: "swift-nio-extras", - condition: .when(traits: ["ServiceLifecycle"]) - ), + .product(name: "NIOExtras", package: "swift-nio-extras"), .product(name: "HTTPServer", package: "swift-http-api-proposal"), ], swiftSettings: extraSettings @@ -110,16 +101,8 @@ let package = Package( name: "NIOHTTPServerTests", dependencies: [ .product(name: "Logging", package: "swift-log"), - .product( - name: "ServiceLifecycle", - package: "swift-service-lifecycle", - condition: .when(traits: ["ServiceLifecycle"]) - ), - .product( - name: "ServiceLifecycleTestKit", - package: "swift-service-lifecycle", - condition: .when(traits: ["ServiceLifecycle"]) - ), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle"), "NIOHTTPServer", ] ), diff --git a/README.md b/README.md index 39e07d3..c77a295 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,7 @@ To get started, please refer to the project's documentation and the Example loca This package offers additional integrations you can enable using [package traits](https://docs.swift.org/swiftpm/documentation/packagemanagerdocs/addingdependencies#Packages-with-Traits). -To enable an additional trait on the package, update the package dependency: - -```diff -.package( - url: "https://github.com/swift-server/swift-http-server.git", - from: "...", -+ traits: [.defaults, "ServiceLifecycle"] -) -``` Available traits: - **`Configuration`** (default): Enables initializing `NIOHTTPServerConfiguration` from a `swift-configuration` `ConfigProvider`. -- **`ServiceLifecycle`** (opt-in): Enables `HTTPService`, which allows the server to be run with `ServiceGroup` from - `swift-service-lifecycle`, including support for graceful shutdown. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 3f67eb4..8b7ae6b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -15,15 +15,12 @@ import HTTPServer import NIOCore import NIOEmbedded +import NIOExtras import NIOHTTP1 import NIOHTTPTypes import NIOHTTPTypesHTTP1 import NIOPosix -#if ServiceLifecycle -import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. -#endif - @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { func serveInsecureHTTP1_1( @@ -48,15 +45,11 @@ extension NIOHTTPServer { let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) .serverChannelInitializer { channel in - #if ServiceLifecycle channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } - #else - channel.eventLoop.makeSucceededVoidFuture() - #endif // ServiceLifecycle } .bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 8faab6a..5415fe0 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -16,6 +16,7 @@ import HTTPServer import Logging import NIOCore import NIOEmbedded +import NIOExtras import NIOHTTP1 import NIOHTTP2 import NIOHTTPTypes @@ -25,10 +26,6 @@ import NIOPosix import NIOSSL import X509 -#if ServiceLifecycle -import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. -#endif - @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { func serveSecureUpgrade( @@ -67,15 +64,11 @@ extension NIOHTTPServer { let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) .serverChannelInitializer { channel in - #if ServiceLifecycle channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } - #else - channel.eventLoop.makeSucceededVoidFuture() - #endif // ServiceLifecycle } .bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 45cb805..a56ecca 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,14 +26,11 @@ import NIOHTTPTypesHTTP1 import NIOHTTPTypesHTTP2 import NIOPosix import NIOSSL +import ServiceLifecycle import SwiftASN1 import Synchronization import X509 -#if ServiceLifecycle -import NIOExtras // For ServerQuiescingHelper, which is used for graceful shutdown. -#endif - /// A generic HTTP server that can handle incoming HTTP requests. /// /// The `Server` class provides a high-level interface for creating HTTP servers with support for: @@ -89,9 +87,7 @@ public struct NIOHTTPServer: HTTPServer { let logger: Logger private let configuration: NIOHTTPServerConfiguration - #if ServiceLifecycle let serverQuiescingHelper: ServerQuiescingHelper - #endif var listeningAddressState: NIOLockedValueBox @@ -115,9 +111,7 @@ public struct NIOHTTPServer: HTTPServer { let eventLoopGroup: MultiThreadedEventLoopGroup = .singletonMultiThreadedEventLoopGroup self.listeningAddressState = .init(.idle(eventLoopGroup.any().makePromise())) - #if ServiceLifecycle self.serverQuiescingHelper = .init(group: eventLoopGroup) - #endif } /// Starts an HTTP server with the specified request handler. diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index ec068ae..e490fd4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -#if ServiceLifecycle import AsyncStreaming import HTTPServer import HTTPTypes @@ -184,4 +183,3 @@ struct NIOHTTPServiceLifecycleTests { } } } -#endif // ServiceLifecycle From 91298cf87f1cb45ed1046b550beafaa745e83709 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 12:52:31 +0000 Subject: [PATCH 20/26] Support graceful shutdown in `serve(handler:)` --- Sources/NIOHTTPServer/NIOHTTPServer.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index a56ecca..0a840ac 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -155,8 +155,16 @@ public struct NIOHTTPServer: HTTPServer { public func serve( handler: some HTTPServerRequestHandler ) async throws { - defer { self.close() } + try await withTaskCancellationOrGracefulShutdownHandler { + try await self._serve(handler: handler) + } onCancelOrGracefulShutdown: { + self.close() + } + } + private func _serve( + handler: some HTTPServerRequestHandler + ) async throws { let asyncChannelConfiguration: NIOAsyncChannel.Configuration switch self.configuration.backpressureStrategy.backing { case .watermark(let low, let high): @@ -336,6 +344,8 @@ public struct NIOHTTPServer: HTTPServer { case .doNothing: () } + + self.serverQuiescingHelper.initiateShutdown(promise: nil) } } From 8aa2e2b4b16589b2425950786bb4bf515de514d8 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 12:52:38 +0000 Subject: [PATCH 21/26] Update tests --- Package.swift | 3 +- .../NIOHTTPServer+ServiceLifecycleTests.swift | 174 ++++++++++-------- 2 files changed, 98 insertions(+), 79 deletions(-) diff --git a/Package.swift b/Package.swift index 52a1594..fc79efe 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,8 @@ let package = Package( .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.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.9.1"), + // TODO: Update once changes are merged upstream. + .package(url: "https://github.com/gjcairo/swift-service-lifecycle.git", branch: "runner"), ], targets: [ .executableTarget( diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index e490fd4..7d9af37 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -42,68 +42,77 @@ struct NIOHTTPServiceLifecycleTests { configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - // Create a promise that will be fulfilled when the server receives the request. When this promise is fulfilled, - // we can initiate the graceful shutdown. + // Create a promise that will be fulfilled when the server receives *part* of the request body. When this + // promise is fulfilled, we can initiate the graceful shutdown and then send the remaining body. If the server + // gracefully shuts down, we should be able to successfully complete the request. let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup - let requestReceivedPromise = elg.any().makePromise(of: Void.self) + let partialRequestBodyReceivedPromise = elg.any().makePromise(of: Void.self) - let serverService = HTTPService(server: server) { request, requestContext, reader, responseWriter in - requestReceivedPromise.succeed() + let serverService = ClosureService { + try await server.serve { request, requestContext, reader, responseWriter in + // The server is expecting 2 `Self.bodyData` parts. After the client sends the first body part, graceful + // shutdown is triggered. The client should be able to send the second body part and complete the + // in-flight request before the server shuts down. + _ = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } - // The server is expecting 2 `Self.bodyData` parts. After the client sends the first body part, graceful - // shutdown is triggered. The client should be able to send the second body part and complete the inflight - // request before the server shuts down. - _ = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - try await bodyReader.collect(upTo: Self.bodyData.readableBytes * 2) { _ in } - } + partialRequestBodyReceivedPromise.succeed() - let responseBodyWriter = try await responseWriter.send(.init(status: .ok)) - try await responseBodyWriter.produceAndConclude { - (writer: consuming HTTPResponseConcludingAsyncWriter.ResponseBodyAsyncWriter) in - try await writer.write([1, 2].span) - return .none + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + } + + let responseBodyWriter = try await responseWriter.send(.init(status: .ok)) + try await responseBodyWriter.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return .none + } } } - try await testGracefulShutdown { trigger in - try await withThrowingTaskGroup { group in - let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) - group.addTask { try await serviceGroup.run() } + try await confirmation { responseReceived in + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + group.addTask { try await serviceGroup.run() } - let serverAddress = try await server.listeningAddress + let serverAddress = try await server.listeningAddress - let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) - try await client.executeThenClose { inbound, outbound in - try await outbound.write(Self.reqHead) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) - // Write the first body part. - try await outbound.write(Self.reqBody) + // Write the first body part. + try await outbound.write(Self.reqBody) - // Wait until the server has received the request. - try await requestReceivedPromise.futureResult.get() + // Wait until the server has received the first body part. + try await partialRequestBodyReceivedPromise.futureResult.get() - // Start the shutdown - trigger.triggerGracefulShutdown() + // 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) + // 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) + 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) + } } - } - // The server should now shut down. Wait for this. - try await group.waitForAll() + responseReceived() + + // The server should now shut down. Wait for this. + try await group.waitForAll() + } } } } @@ -132,51 +141,60 @@ struct NIOHTTPServiceLifecycleTests { let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup let requestReceivedPromise = elg.any().makePromise(of: Void.self) - let serverService = HTTPService(server: server) { request, requestContext, reader, responseWriter in - requestReceivedPromise.succeed() + let serverService = ClosureService { + try await server.serve { request, requestContext, reader, responseWriter in + _ = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + let error = try await #require(throws: EitherError.self) { + try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } + + requestReceivedPromise.succeed() - // Consume the body: this will block because 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 reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - let error = try await #require(throws: EitherError.self) { - try await bodyReader.collect(upTo: 100) { _ in } + // 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() } } - #expect(throws: RequestBodyReadError.streamEndedBeforeReceivingRequestEnd) { try error.unwrap() } } } - try await testGracefulShutdown { trigger in - try await withThrowingTaskGroup { group in - let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) - group.addTask { try await serviceGroup.run() } + try await confirmation { connectionForcefullyShutdown in + try await testGracefulShutdown { trigger in + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + group.addTask { try await serviceGroup.run() } - let serverAddress = try await server.listeningAddress + let serverAddress = try await server.listeningAddress - let client = try await setUpClientWithMTLS( - at: serverAddress, - chain: clientChain, - trustRoots: [serverChain.ca], - applicationProtocol: "h2" - ) + 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) + 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 requestReceivedPromise.futureResult.get() + // Wait until the server has received the request. + try await requestReceivedPromise.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() + // 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 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) + // The connection should have been closed: we should get an `ioOnClosedChannel` error. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(Self.reqEnd) + } + + connectionForcefullyShutdown() } } } From e553a8ff4c133bf2966c0ab4033f99a2105b235a Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 13:18:22 +0000 Subject: [PATCH 22/26] Formatting --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 7d9af37..4955fcc 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -44,7 +44,7 @@ struct NIOHTTPServiceLifecycleTests { // Create a promise that will be fulfilled when the server receives *part* of the request body. When this // promise is fulfilled, we can initiate the graceful shutdown and then send the remaining body. If the server - // gracefully shuts down, we should be able to successfully complete the request. + // gracefully shuts down, we should be able to successfully complete the request. let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup let partialRequestBodyReceivedPromise = elg.any().makePromise(of: Void.self) From 9d4d54a65306a7597976f44117b01b7cd24677e4 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 13:51:37 +0000 Subject: [PATCH 23/26] Update `swift-service-lifecycle` dependency --- Package.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index fc79efe..cd1de90 100644 --- a/Package.swift +++ b/Package.swift @@ -54,8 +54,7 @@ let package = Package( .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.40.0"), .package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0"), - // TODO: Update once changes are merged upstream. - .package(url: "https://github.com/gjcairo/swift-service-lifecycle.git", branch: "runner"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", branch: "2.10.0"), ], targets: [ .executableTarget( From 2adab4a594f021102ce926775f98412a0fe19c7b Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Feb 2026 14:12:29 +0000 Subject: [PATCH 24/26] Fix Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index cd1de90..cc27c04 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,7 @@ let package = Package( .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.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", branch: "2.10.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.10.0"), ], targets: [ .executableTarget( From 883469245677a80b0b20a645d1d083504204a300 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 25 Feb 2026 11:21:04 +0000 Subject: [PATCH 25/26] Forcefully close upon server task cancellation; rename `maxGraceTime` argument --- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 43 +++---- .../NIOHTTPServer+SecureUpgrade.swift | 120 ++++++++--------- .../NIOHTTPServer+SwiftConfiguration.swift | 8 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 121 ++++++++++++------ .../NIOHTTPServerConfiguration.swift | 14 +- Sources/NIOHTTPServer/ServerChannel.swift | 26 ++++ .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 +- ...NIOHTTPServerSwiftConfigurationTests.swift | 8 +- .../Utilities/NIOHTTPServer+HTTP1.swift | 2 +- .../NIOHTTPServer+SecureUpgrade.swift | 2 +- 10 files changed, 195 insertions(+), 151 deletions(-) create mode 100644 Sources/NIOHTTPServer/ServerChannel.swift diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 8b7ae6b..38b8494 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -24,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> { @@ -77,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 5415fe0..39b0b03 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -28,31 +28,60 @@ 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: NIOHTTPServerConfiguration.HTTP2, - 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( + func serveSecureUpgrade( + serverChannel: NIOAsyncChannel, Never>, + handler: some HTTPServerRequestHandler + ) async throws { + try await withThrowingDiscardingTaskGroup { group in + try await serverChannel.executeThenClose { inbound in + for try await upgradeResult in inbound { + group.addTask { + do { + try await withThrowingDiscardingTaskGroup { connectionGroup in + switch try await upgradeResult.get() { + case .http1_1(let http1Channel): + let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() + Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } + } + case .http2((let http2Connection, let http2Multiplexer)): + do { + let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() + try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + for try await http2StreamChannel in http2Multiplexer.inbound { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http2StreamChannel, + handler: handler + ) + } + } + } + } catch { + self.logger.debug("HTTP2 connection closed: \(error)") + } + } + } + } catch { + self.logger.debug("Negotiating ALPN failed: \(error)") + } + } + } + } + } + } + + func setupSecureUpgradeServerChannel( bindTarget: NIOHTTPServerConfiguration.BindTarget, tlsConfiguration: TLSConfiguration, asyncChannelConfiguration: NIOAsyncChannel.Configuration, @@ -118,7 +147,8 @@ extension NIOHTTPServer { connectionManagerConfiguration: .init( maxIdleTime: nil, maxAge: nil, - maxGraceTime: http2Configuration.gracefulShutdown.maxGraceTime, + maxGraceTime: http2Configuration.gracefulShutdown.maximumGracefulShutdownDuration + .map { TimeAmount($0) }, keepalive: nil ), http2HandlerConfiguration: .init(httpServerHTTP2Configuration: http2Configuration), @@ -144,54 +174,6 @@ extension NIOHTTPServer { ) } } - - func _serveSecureUpgrade( - serverChannel: NIOAsyncChannel, Never>, - handler: some HTTPServerRequestHandler - ) async throws { - try await withThrowingDiscardingTaskGroup { group in - try await serverChannel.executeThenClose { inbound in - for try await upgradeResult in inbound { - group.addTask { - do { - try await withThrowingDiscardingTaskGroup { connectionGroup in - switch try await upgradeResult.get() { - case .http1_1(let http1Channel): - let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() - Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) - } - } - case .http2((let http2Connection, let http2Multiplexer)): - do { - let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() - try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - for try await http2StreamChannel in http2Multiplexer.inbound { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http2StreamChannel, - handler: handler - ) - } - } - } - } catch { - self.logger.debug("HTTP2 connection closed: \(error)") - } - } - } - } catch { - self.logger.debug("Negotiating ALPN failed: \(error)") - } - } - } - } - } - } } @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 b3035ba..0bff668 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift @@ -313,12 +313,14 @@ extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { /// Initialize a HTTP/2 graceful shutdown configuration from a config reader. /// /// ## Configuration keys: - /// - `maxGraceTimeSeconds` (int, optional, default: nil): The maximum amount of time (in seconds) that the - /// connection has to close gracefully. + /// - `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(maxGraceTime: config.int(forKey: "maxGraceTimeSeconds").map { .seconds(Int64($0)) }) + self.init( + maximumGracefulShutdownDuration: config.int(forKey: "maximumGracefulShutdownDuration").map { .seconds($0) } + ) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 0a840ac..2d4e330 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -155,16 +155,22 @@ public struct NIOHTTPServer: HTTPServer { public func serve( handler: some HTTPServerRequestHandler ) async throws { - try await withTaskCancellationOrGracefulShutdownHandler { - try await self._serve(handler: handler) - } onCancelOrGracefulShutdown: { - self.close() + 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) } } - private func _serve( - handler: some HTTPServerRequestHandler - ) async throws { + /// 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): @@ -176,10 +182,11 @@ 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): @@ -192,12 +199,14 @@ 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: self.configuration.http2 + return .secureUpgrade( + try await self.setupSecureUpgradeServerChannel( + bindTarget: self.configuration.bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: self.configuration.http2, + verificationCallback: nil + ) ) case .reloadingTLS(let certificateReloader): @@ -206,12 +215,14 @@ 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: self.configuration.http2 + 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): @@ -227,13 +238,14 @@ 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: self.configuration.http2, - 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): @@ -246,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: self.configuration.http2, - 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 @@ -337,16 +363,35 @@ public struct NIOHTTPServer: HTTPServer { } } - func close() { + /// 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 9b8c3fd..183a7f7 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// public import NIOCertificateReloading -public import NIOCore +import NIOCore import NIOSSL public import X509 @@ -210,14 +210,16 @@ public struct NIOHTTPServerConfiguration: Sendable { 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 maxGraceTime: TimeAmount? + public var maximumGracefulShutdownDuration: Duration? /// Creates a graceful shutdown configuration with the specified timeout value. + /// /// - Parameters: - /// - maxGraceTime: 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(maxGraceTime: TimeAmount? = nil) { - self.maxGraceTime = maxGraceTime + /// - 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 } } 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 index 4955fcc..41ab11e 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -132,7 +132,7 @@ struct NIOHTTPServiceLifecycleTests { certificateChain: serverChain.chain, privateKey: serverChain.privateKey ), - http2: .init(gracefulShutdown: .init(maxGraceTime: .milliseconds(500))) + http2: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500))) ) ) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 0b4424f..f5635d8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -141,7 +141,7 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize) #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) #expect(http2.maxConcurrentStreams == nil) - #expect(http2.gracefulShutdown == .init(maxGraceTime: nil)) + #expect(http2.gracefulShutdown == .init(maximumGracefulShutdownDuration: nil)) } @Test("Custom values") @@ -151,7 +151,7 @@ struct NIOHTTPServerSwiftConfigurationTests { "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, - "maxGraceTimeSeconds": 4, + "maximumGracefulShutdownDuration": 4, ]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -161,7 +161,7 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == 1) #expect(http2.targetWindowSize == 2) #expect(http2.maxConcurrentStreams == 3) - #expect(http2.gracefulShutdown.maxGraceTime == .seconds(4)) + #expect(http2.gracefulShutdown.maximumGracefulShutdownDuration == .seconds(4)) } @Test("Partial custom values") @@ -176,7 +176,7 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(http2.maxFrameSize == 5) #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) #expect(http2.maxConcurrentStreams == nil) - #expect(http2.gracefulShutdown.maxGraceTime == nil) + #expect(http2.gracefulShutdown.maximumGracefulShutdownDuration == nil) } } 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) } } From cf189f5537d259a26531445b536cebe45f48e02b Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 25 Feb 2026 14:48:40 +0000 Subject: [PATCH 26/26] Add test --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 132 +++++++++++++----- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 41ab11e..8e95e9e 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -34,35 +34,35 @@ struct NIOHTTPServiceLifecycleTests { static let trailer: HTTPFields = [.trailer: "test_trailer"] static let reqEnd = HTTPRequestPart.end(trailer) - @Test("HTTP/1.1 in-flight request completes after graceful shutdown triggered") + 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 testHTTP1ConnectionInFlightRequestCompletesDuringGracefulShutdown() async throws { + func activeHTTP1ConnectionCanCompleteWhenGracefulShutdown() async throws { let server = NIOHTTPServer( - logger: Logger(label: "Test"), + logger: self.serverLogger, configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - // Create a promise that will be fulfilled when the server receives *part* of the request body. When this - // promise is fulfilled, we can initiate the graceful shutdown and then send the remaining body. If the server - // gracefully shuts down, we should be able to successfully complete the request. + // 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 partialRequestBodyReceivedPromise = elg.any().makePromise(of: Void.self) + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) let serverService = ClosureService { - try await server.serve { request, requestContext, reader, responseWriter in - // The server is expecting 2 `Self.bodyData` parts. After the client sends the first body part, graceful - // shutdown is triggered. The client should be able to send the second body part and complete the - // in-flight request before the server shuts down. - _ = try await reader.consumeAndConclude { bodyReader in + 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 } - partialRequestBodyReceivedPromise.succeed() + firstChunkReadPromise.succeed() try await bodyReader.read(maximumCount: Self.bodyData.readableBytes) { _ in } } - let responseBodyWriter = try await responseWriter.send(.init(status: .ok)) + 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) @@ -74,7 +74,7 @@ struct NIOHTTPServiceLifecycleTests { try await confirmation { responseReceived in try await testGracefulShutdown { trigger in try await withThrowingTaskGroup { group in - let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) group.addTask { try await serviceGroup.run() } let serverAddress = try await server.listeningAddress @@ -88,9 +88,9 @@ struct NIOHTTPServiceLifecycleTests { try await outbound.write(Self.reqBody) // Wait until the server has received the first body part. - try await partialRequestBodyReceivedPromise.futureResult.get() + try await firstChunkReadPromise.futureResult.get() - // Start the shutdown + // Start the shutdown. trigger.triggerGracefulShutdown() // We should be able to complete our request. @@ -118,37 +118,105 @@ struct NIOHTTPServiceLifecycleTests { } } - @Test("Long-running HTTP/2 connection is forcefully shut down upon graceful shutdown timeout") + @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 testLongRunningHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { + func testActiveHTTP2ConnectionIsShutDownAfterGraceTimeout() async throws { let serverChain = try TestCA.makeSelfSignedChain() let clientChain = try TestCA.makeSelfSignedChain() let server = NIOHTTPServer( - logger: Logger(label: "Test"), + logger: self.serverLogger, configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - transportSecurity: .tls( - certificateChain: serverChain.chain, - privateKey: serverChain.privateKey - ), + transportSecurity: .tls(certificateChain: serverChain.chain, privateKey: serverChain.privateKey), http2: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500))) ) ) - // Create a promise that will be fulfilled when the server receives the request. When this promise is fulfilled, - // we can initiate the graceful shutdown. + // 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 requestReceivedPromise = elg.any().makePromise(of: Void.self) + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) let serverService = ClosureService { - try await server.serve { request, requestContext, reader, responseWriter in - _ = try await reader.consumeAndConclude { bodyReader in + 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 } - requestReceivedPromise.succeed() + 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. @@ -162,7 +230,7 @@ struct NIOHTTPServiceLifecycleTests { try await confirmation { connectionForcefullyShutdown in try await testGracefulShutdown { trigger in try await withThrowingTaskGroup { group in - let serviceGroup = ServiceGroup(services: [serverService], logger: .init(label: "test")) + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) group.addTask { try await serviceGroup.run() } let serverAddress = try await server.listeningAddress @@ -179,7 +247,7 @@ struct NIOHTTPServiceLifecycleTests { try await outbound.write(Self.reqBody) // Wait until the server has received the request. - try await requestReceivedPromise.futureResult.get() + 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