diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 241f287..79d4bbe 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -29,7 +29,12 @@ extension NIOHTTPServerConfiguration { /// ``NIOHTTPServerConfiguration`` is comprised of four types. Provide configuration for each type under the /// specified key: /// - /// - **`"bindTarget"`**: The address and port to bind to (see ``BindTarget/init(config:)``). + /// - **`"bindTarget"`**: A single address and port to bind to (see ``BindTarget/init(config:)``). Use this when + /// binding to exactly one address. + /// + /// - **`"bindTargets"`**: Multiple addresses to bind to, provided as parallel string and int arrays under + /// `bindTargets.hosts` and `bindTargets.ports`. Exactly one of `"bindTarget"` or `"bindTargets"` must be + /// provided. /// /// - **`"http"`**: Supported HTTP versions and protocol settings. Supported keys are `"versions"` /// (a string array of `"http1_1"` and/or `"http2"`) and, when HTTP/2 is enabled, `"http2"` (see @@ -50,6 +55,10 @@ extension NIOHTTPServerConfiguration { /// - Throws `NIOHTTPServerSwiftConfigurationError/trustRootsSourceAndVerificationCallbackMismatch` if there /// is a mismatch between `transportSecurity.trustRootsSource` and whether a custom certificate verification /// callback is provided. + /// - Throws `NIOHTTPServerSwiftConfigurationError/singularAndPluralBindTargetsProvided` if both + /// `"bindTarget"` and `"bindTargets"` are provided. + /// - Throws `NIOHTTPServerSwiftConfigurationError/bindTargetsHostsAndPortsLengthMismatch` if + /// `bindTargets.hosts` and `bindTargets.ports` have different lengths. public init( config: ConfigReader, customCertificateVerificationCallback: ( @@ -59,7 +68,7 @@ extension NIOHTTPServerConfiguration { let snapshot = config.snapshot() try self.init( - bindTarget: try .init(config: snapshot.scoped(to: "bindTarget")), + bindTargets: try Self.readBindTargets(from: snapshot), supportedHTTPVersions: try .init(config: snapshot.scoped(to: "http")), transportSecurity: try .init( config: snapshot.scoped(to: "transportSecurity"), @@ -68,6 +77,37 @@ extension NIOHTTPServerConfiguration { backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")) ) } + + /// Reads bind targets from either the singular `bindTarget` scope or the plural `bindTargets` scope. + /// Exactly one of the two must be provided. + private static func readBindTargets( + from snapshot: ConfigSnapshotReader + ) throws -> [BindTarget] { + let bindTargetsScope = snapshot.scoped(to: "bindTargets") + let hosts = bindTargetsScope.stringArray(forKey: "hosts") + let ports = bindTargetsScope.intArray(forKey: "ports") + let hasPlural = hosts != nil || ports != nil + + let bindTargetScope = snapshot.scoped(to: "bindTarget") + let singularHost = bindTargetScope.string(forKey: "host") + let singularPort = bindTargetScope.int(forKey: "port") + let hasSingular = singularHost != nil || singularPort != nil + + if hasSingular && hasPlural { + throw NIOHTTPServerSwiftConfigurationError.singularAndPluralBindTargetsProvided + } + + if hasPlural { + let hosts = hosts ?? [] + let ports = ports ?? [] + guard hosts.count == ports.count else { + throw NIOHTTPServerSwiftConfigurationError.bindTargetsHostsAndPortsLengthMismatch + } + return zip(hosts, ports).map { .hostAndPort(host: $0, port: $1) } + } + + return [try BindTarget(config: bindTargetScope)] + } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index b64f29e..d39e39c 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -230,8 +230,8 @@ public struct NIOHTTPServerConfiguration: Sendable { } } - /// Network binding configuration - public var bindTarget: BindTarget + /// Network binding configuration specifying all addresses where the server should listen. + public var bindTargets: [BindTarget] /// TLS configuration for the server. public var transportSecurity: TransportSecurity @@ -242,19 +242,23 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Backpressure strategy to use in the server. public var backpressureStrategy: BackPressureStrategy - /// Create a new configuration. + /// Create a new configuration with multiple bind targets. /// - Parameters: - /// - bindTarget: A ``BindTarget``. + /// - bindTargets: An array of ``BindTarget`` values specifying where the server should listen. /// - supportedHTTPVersions: The HTTP protocol versions the server should support. /// - transportSecurity: The transport security mode (plaintext, TLS, or mTLS). /// - backpressureStrategy: A ``BackPressureStrategy``. /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. public init( - bindTarget: BindTarget, + bindTargets: [BindTarget], supportedHTTPVersions: Set, transportSecurity: TransportSecurity, backpressureStrategy: BackPressureStrategy = .defaults ) throws { + if bindTargets.isEmpty { + throw NIOHTTPServerConfigurationError.noBindTargetsSpecified + } + // If `transportSecurity`` is set to `.plaintext`, the server can only support HTTP/1.1. // To support HTTP/2, `transportSecurity` must be set to `.tls` or `.mTLS`. if case .plaintext = transportSecurity.backing { @@ -267,11 +271,32 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } - self.bindTarget = bindTarget + self.bindTargets = bindTargets self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity self.backpressureStrategy = backpressureStrategy } + + /// Create a new configuration with a single bind target. + /// - Parameters: + /// - bindTarget: A ``BindTarget``. + /// - supportedHTTPVersions: The HTTP protocol versions the server should support. + /// - transportSecurity: The transport security mode (plaintext, TLS, or mTLS). + /// - backpressureStrategy: A ``BackPressureStrategy``. + /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. + public init( + bindTarget: BindTarget, + supportedHTTPVersions: Set, + transportSecurity: TransportSecurity, + backpressureStrategy: BackPressureStrategy = .defaults + ) throws { + try self.init( + bindTargets: [bindTarget], + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: transportSecurity, + backpressureStrategy: backpressureStrategy + ) + } } /// Represents the outcome of certificate verification. diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 2001f29..7f56f9c 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -16,6 +16,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case noSupportedHTTPVersionsSpecified case incompatibleTransportSecurity + case noBindTargetsSpecified var description: String { switch self { @@ -24,6 +25,9 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case .incompatibleTransportSecurity: "Invalid configuration: only HTTP/1.1 can be served over plaintext. `transportSecurity` must be set to (m)TLS for serving HTTP/2." + + case .noBindTargetsSpecified: + "Invalid configuration: at least one bind target must be specified." } } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift index 9d73dc6..6508d70 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift @@ -19,6 +19,8 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case customVerificationCallbackAndTrustRootsProvided case customVerificationCallbackProvidedWhenNotUsingMTLS case trustRootsSourceAndVerificationCallbackMismatch + case singularAndPluralBindTargetsProvided + case bindTargetsHostsAndPortsLengthMismatch var description: String { switch self { @@ -30,6 +32,12 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case .trustRootsSourceAndVerificationCallbackMismatch: "Invalid configuration: there is a mismatch between the trustRootsSource key and the provided customCertificateVerificationCallback." + + case .singularAndPluralBindTargetsProvided: + "Invalid configuration: both the singular 'bindTarget' scope and the plural 'bindTargets' scope were provided. Use only one." + + case .bindTargetsHostsAndPortsLengthMismatch: + "Invalid configuration: 'bindTargets.hosts' and 'bindTargets.ports' must have the same number of elements." } } } diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 005fce3..5cbcb7b 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -32,13 +32,18 @@ let serverConfiguration = try NIOHTTPServerConfiguration(config: config) ``NIOHTTPServerConfiguration`` is comprised of four components. Provide the configuration for each component under its respective key prefix. +> Important: Exactly one of `bindTarget` (singular, for a single address) or `bindTargets` (plural, for multiple +> addresses) must be provided. Providing both results in an error. + > Important: HTTP/2 cannot be served over plaintext. If `"http2"` is included in `http.versions`, the transport > security must be set to `"tls"` or `"mTLS"`. | Prefix | Configuration Key | Type | Required/Optional | Default | |-------------------------------|-----------------------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|---------| -| `bindTarget` | `host` | `string` | Required | - | -| | `port` | `int` | Required | - | +| `bindTarget` | `host` | `string` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | +| | `port` | `int` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | +| `bindTargets` | `hosts` | `string array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `ports` | - | +| | `ports` | `int array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `hosts` | - | | `http` | `versions` | `string array` | Required (permitted values: `"http1_1"`, `"http2"`) | - | | `http.http2` | `maxFrameSize` | `int` | Optional | 2^14 | | | `targetWindowSize` | `int` | Optional | 2^16-1 | @@ -112,6 +117,19 @@ key were omitted. } ``` +To bind to multiple addresses, replace `bindTarget` with `bindTargets`, providing parallel `hosts` and `ports` arrays +of the same length: + +```json +{ + "bindTargets": { + "hosts": ["0.0.0.0", "::"], + "ports": [443, 443] + }, + // ...rest of the configuration +} +``` + ### Custom certificate verification When using mTLS, you can provide a custom certificate verification callback instead of relying on trust roots. To do diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index d8c2f01..fbe7a7d 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -62,34 +62,50 @@ extension NIOHTTPServer { } } - func setupHTTP1_1ServerChannel( - bindTarget: NIOHTTPServerConfiguration.BindTarget - ) async throws -> NIOAsyncChannel, Never> { - switch bindTarget.backing { - case .hostAndPort(let host, let port): - let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .serverChannelInitializer { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler( - self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) - ) - } - } - .bind(host: host, port: port) { channel in - self.setupHTTP1_1ConnectionChildChannel( - channel: channel, - asyncChannelConfiguration: .init( - backPressureStrategy: .init(self.configuration.backpressureStrategy), - isOutboundHalfClosureEnabled: true - ) + func setupHTTP1_1ServerChannels( + bindTargets: [NIOHTTPServerConfiguration.BindTarget] + ) async throws -> [NIOAsyncChannel, Never>] { + let bootstrap = ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } + } - try self.addressBound(serverChannel.channel.localAddress) - - return serverChannel + var serverChannels = [NIOAsyncChannel, Never>]() + do { + for bindTarget in bindTargets { + switch bindTarget.backing { + case .hostAndPort(let host, let port): + let serverChannel = + try await bootstrap.bind(host: host, port: port) { channel in + self.setupHTTP1_1ConnectionChildChannel( + channel: channel, + asyncChannelConfiguration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ) + ) + } + serverChannels.append(serverChannel) + } + } + } catch { + // A later bind failed: close any channels we already bound to avoid leaking sockets. + // We await the closes so the sockets are fully released by the time we throw, giving the + // caller deterministic semantics: when `serve` throws, all cleanup is done. + for serverChannel in serverChannels { + try? await serverChannel.channel.close() + } + throw error } + + try self.addressesBound(serverChannels.map { $0.channel.localAddress }) + + return serverChannels } func setupHTTP1_1ConnectionChildChannel( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index c7cb465..8d2b66f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -37,25 +37,27 @@ enum ListeningAddressError: CustomStringConvertible, Error { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { - func addressBound(_ address: NIOCore.SocketAddress?) throws { - switch self.listeningAddressState.withLockedValue({ $0.addressBound(address) }) { - case .succeedPromise(let promise, let boundAddress): - promise.succeed(boundAddress) + func addressesBound(_ addresses: [NIOCore.SocketAddress?]) throws { + switch self.listeningAddressState.withLockedValue({ $0.addressesBound(addresses) }) { + case .succeedPromise(let promise, let boundAddresses): + promise.succeed(boundAddresses) case .failPromise(let promise, let error): promise.fail(error) } } - /// The address the server is listening from. + /// The addresses the server is listening on. /// - /// It is an `async` property because it will only return once the address has been successfully bound. + /// This property returns one ``SocketAddress`` per ``NIOHTTPServerConfiguration/bindTargets`` entry. + /// It suspends until **all** bind targets have been successfully bound. If any single bind fails, no addresses are returned: + /// the server treats its listening addresses as an all-or-nothing unit. See ``serve(handler:)`` for the full semantics. /// - /// - Throws: An error will be thrown if the address could not be bound or is not bound any longer because the - /// server isn't listening anymore. - public var listeningAddress: SocketAddress { + /// - Throws: An error will be thrown if the addresses could not be bound or are not bound any longer because the + /// server isn't listening anymore (for example, after ``serve(handler:)`` has returned). + public var listeningAddresses: [SocketAddress] { get async throws { try await self.listeningAddressState - .withLockedValue { try $0.listeningAddressFuture } + .withLockedValue { try $0.listeningAddressesFuture } .get() } } @@ -64,11 +66,11 @@ extension NIOHTTPServer { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServer { enum State { - case idle(EventLoopPromise) - case listening(EventLoopFuture) + case idle(EventLoopPromise<[SocketAddress]>) + case listening(EventLoopFuture<[SocketAddress]>) case closedOrInvalidAddress(ListeningAddressError) - var listeningAddressFuture: EventLoopFuture { + var listeningAddressesFuture: EventLoopFuture<[SocketAddress]> { get throws { switch self { case .idle(let eventLoopPromise): @@ -82,29 +84,33 @@ extension NIOHTTPServer { } enum OnBound { - case succeedPromise(_ promise: EventLoopPromise, address: SocketAddress) - case failPromise(_ promise: EventLoopPromise, error: ListeningAddressError) + case succeedPromise(_ promise: EventLoopPromise<[SocketAddress]>, addresses: [SocketAddress]) + case failPromise(_ promise: EventLoopPromise<[SocketAddress]>, error: ListeningAddressError) } - mutating func addressBound(_ address: NIOCore.SocketAddress?) -> OnBound { + mutating func addressesBound(_ addresses: [NIOCore.SocketAddress?]) -> OnBound { switch self { case .idle(let listeningAddressPromise): - do { - let socketAddress = try SocketAddress(address) + var socketAddresses = [SocketAddress]() + socketAddresses.reserveCapacity(addresses.count) + do throws(ListeningAddressError) { + for address in addresses { + try socketAddresses.append(SocketAddress(address)) + } self = .listening(listeningAddressPromise.futureResult) - return .succeedPromise(listeningAddressPromise, address: socketAddress) + return .succeedPromise(listeningAddressPromise, addresses: socketAddresses) } catch { self = .closedOrInvalidAddress(error) return .failPromise(listeningAddressPromise, error: error) } case .listening, .closedOrInvalidAddress: - fatalError("Invalid state: addressBound should only be called once and when in idle state") + fatalError("Invalid state: addressesBound should only be called once and when in idle state") } } enum OnClose { - case failPromise(_ promise: EventLoopPromise, error: ListeningAddressError) + case failPromise(_ promise: EventLoopPromise<[SocketAddress]>, error: ListeningAddressError) case doNothing } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 3b708f0..5c24a2f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -153,34 +153,50 @@ extension NIOHTTPServer { } } - func setupSecureUpgradeServerChannel( - bindTarget: NIOHTTPServerConfiguration.BindTarget, + func setupSecureUpgradeServerChannels( + bindTargets: [NIOHTTPServerConfiguration.BindTarget], supportedHTTPVersions: Set, tlsConfiguration: TLSConfiguration - ) async throws -> NIOAsyncChannel, Never> { - switch bindTarget.backing { - case .hostAndPort(let host, let port): - let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .serverChannelInitializer { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler( - self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) - ) - } - } - .bind(host: host, port: port) { channel in - self.setupSecureUpgradeConnectionChildChannel( - channel: channel, - supportedHTTPVersions: supportedHTTPVersions, - tlsConfiguration: tlsConfiguration + ) async throws -> [NIOAsyncChannel, Never>] { + let bootstrap = ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .serverChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) } + } - try self.addressBound(serverChannel.channel.localAddress) - - return serverChannel + var serverChannels = [NIOAsyncChannel, Never>]() + do { + for bindTarget in bindTargets { + switch bindTarget.backing { + case .hostAndPort(let host, let port): + let serverChannel = + try await bootstrap.bind(host: host, port: port) { channel in + self.setupSecureUpgradeConnectionChildChannel( + channel: channel, + supportedHTTPVersions: supportedHTTPVersions, + tlsConfiguration: tlsConfiguration + ) + } + serverChannels.append(serverChannel) + } + } + } catch { + // A later bind failed: close any channels we already bound to avoid leaking sockets. + // We await the closes so the sockets are fully released by the time we throw, giving the + // caller deterministic semantics: when `serve` throws, all cleanup is done. + for serverChannel in serverChannels { + try? await serverChannel.channel.close() + } + throw error } + + try self.addressesBound(serverChannels.map { $0.channel.localAddress }) + + return serverChannels } private func http1ConnectionInitializer( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 96755c6..a97a443 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -116,100 +116,108 @@ public struct NIOHTTPServer: HTTPServer { /// Starts an HTTP server with the specified request handler. /// - /// This method creates and runs an HTTP server that processes incoming requests using the provided - /// ``HTTPServerRequestHandler`` implementation. The server binds to the specified configuration and - /// handles each connection concurrently using Swift's structured concurrency. + /// This method binds to all addresses specified in ``NIOHTTPServerConfiguration/bindTargets`` and begins + /// accepting connections on each one. All bind targets share the same request handler, transport security + /// configuration, and supported HTTP versions. /// - /// - Parameters: - /// - logger: A logger instance for recording server events and debugging information. - /// - configuration: The server configuration including bind target and TLS settings. - /// - handler: A ``HTTPServerRequestHandler`` implementation that processes incoming HTTP requests. The handler - /// receives each request along with a body reader and response sender function. + /// ## All-or-nothing listening /// - /// ## Example + /// The server treats its set of listening addresses as a single unit. If any one of the bound addresses + /// stops listening — whether due to its underlying socket closing, an unrecoverable error on the + /// listening channel, or any other reason — the server stops listening on **all** remaining addresses + /// and this method returns. After that point, ``listeningAddresses`` will throw + /// ``ListeningAddressError/serverClosed``. /// - /// ```swift - /// struct EchoHandler: HTTPServerRequestHandler { - /// func handle( - /// request: HTTPRequest, - /// requestBodyAndTrailers: HTTPRequestConcludingAsyncReader, - /// responseSender: @escaping (HTTPResponse) async throws -> HTTPResponseConcludingAsyncWriter - /// ) async throws { - /// let response = HTTPResponse(status: .ok) - /// let writer = try await sendResponse(response) - /// // Handle request and write response... - /// } - /// } + /// This also applies during graceful shutdown and task cancellation: all channels are shut down together. /// - /// let configuration = HTTPServerConfiguration( - /// bindTarget: .hostAndPort(host: "localhost", port: 8080), - /// tlsConfiguration: .insecure() - /// ) + /// - Parameter handler: A ``HTTPServerRequestHandler`` implementation that processes incoming HTTP + /// requests. The handler receives each request along with a body reader and response sender function. + /// + /// ## Example /// - /// try await Server.serve( + /// ```swift + /// let server = NIOHTTPServer( /// logger: logger, - /// configuration: configuration, - /// handler: EchoHandler() + /// configuration: try .init( + /// bindTargets: [ + /// .hostAndPort(host: "0.0.0.0", port: 8080), + /// .hostAndPort(host: "0.0.0.0", port: 8443), + /// ], + /// supportedHTTPVersions: [.http1_1], + /// transportSecurity: .plaintext + /// ) /// ) + /// + /// try await server.serve(handler: MyHandler()) /// ``` public func serve( handler: some HTTPServerRequestHandler ) async throws { - let serverChannel = try await self.makeServerChannel() + // Ensure the listening address promise is always completed on the way out, regardless of whether + // binding succeeded, the serve loop returned normally, or an error propagated. + defer { self.finishListeningAddressPromise() } + + let serverChannels = try await self.makeServerChannels() return try await withTaskCancellationHandler { try await withGracefulShutdownHandler { - try await self._serve(serverChannel: serverChannel, handler: handler) + try await self._serve(serverChannels: serverChannels, handler: handler) } onGracefulShutdown: { self.beginGracefulShutdown() } } onCancel: { - // Forcefully close down the server channel - self.close(serverChannel: serverChannel) + // Forcefully close down the server channels + self.close(serverChannels: serverChannels) } } - /// Creates and returns a server channel based on the configured transport security. - private func makeServerChannel() async throws -> ServerChannel { + /// Creates and returns server channels based on the configured transport security. + private func makeServerChannels() async throws -> [ServerChannel] { switch self.configuration.transportSecurity.backing { case .plaintext: - return .plaintextHTTP1_1( - try await self.setupHTTP1_1ServerChannel(bindTarget: self.configuration.bindTarget) - ) + return try await self.setupHTTP1_1ServerChannels(bindTargets: self.configuration.bindTargets) + .map { .plaintextHTTP1_1($0) } case .tls(let credentials): - return .secureUpgrade( - try await self.setupSecureUpgradeServerChannel( - bindTarget: self.configuration.bindTarget, - supportedHTTPVersions: self.configuration.supportedHTTPVersions, - tlsConfiguration: try .makeServerConfiguration(tlsCredentials: credentials, mTLSConfiguration: nil) - ) - ) + return try await self.setupSecureUpgradeServerChannels( + bindTargets: self.configuration.bindTargets, + supportedHTTPVersions: self.configuration.supportedHTTPVersions, + tlsConfiguration: try .makeServerConfiguration(tlsCredentials: credentials, mTLSConfiguration: nil) + ).map { .secureUpgrade($0) } case .mTLS(let credentials, let mTLSConfiguration): - return .secureUpgrade( - try await self.setupSecureUpgradeServerChannel( - bindTarget: self.configuration.bindTarget, - supportedHTTPVersions: self.configuration.supportedHTTPVersions, - tlsConfiguration: try .makeServerConfiguration( - tlsCredentials: credentials, - mTLSConfiguration: mTLSConfiguration - ) + return try await self.setupSecureUpgradeServerChannels( + bindTargets: self.configuration.bindTargets, + supportedHTTPVersions: self.configuration.supportedHTTPVersions, + tlsConfiguration: try .makeServerConfiguration( + tlsCredentials: credentials, + mTLSConfiguration: mTLSConfiguration ) - ) + ).map { .secureUpgrade($0) } } } private func _serve( - serverChannel: ServerChannel, + serverChannels: [ServerChannel], handler: some HTTPServerRequestHandler ) async throws { - switch serverChannel { - case .plaintextHTTP1_1(let http1Channel): - try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) + try await withThrowingTaskGroup(of: Void.self) { group in + for serverChannel in serverChannels { + group.addTask { + switch serverChannel { + case .plaintextHTTP1_1(let http1Channel): + try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) + + case .secureUpgrade(let secureUpgradeChannel): + try await self.serveSecureUpgrade(serverChannel: secureUpgradeChannel, handler: handler) + } + } + } - case .secureUpgrade(let secureUpgradeChannel): - try await self.serveSecureUpgrade(serverChannel: secureUpgradeChannel, handler: handler) + // Wait for the first channel to complete (either normally or by throwing). + // If any channel stops serving, bring down all remaining channels. + try await group.next() + group.cancelAll() } } @@ -329,16 +337,18 @@ public struct NIOHTTPServer: HTTPServer { self.serverQuiescingHelper.initiateShutdown(promise: nil) } - /// Forcefully closes the server channel without waiting for existing connections to drain. - private func close(serverChannel: ServerChannel) { + /// Forcefully closes the server channels without waiting for existing connections to drain. + private func close(serverChannels: [ServerChannel]) { self.finishListeningAddressPromise() - switch serverChannel { - case .plaintextHTTP1_1(let http1Channel): - http1Channel.channel.close(promise: nil) + for serverChannel in serverChannels { + switch serverChannel { + case .plaintextHTTP1_1(let http1Channel): + http1Channel.channel.close(promise: nil) - case .secureUpgrade(let secureUpgradeChannel): - secureUpgradeChannel.channel.close(promise: nil) + case .secureUpgrade(let secureUpgradeChannel): + secureUpgradeChannel.channel.close(promise: nil) + } } } } diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 04c125c..dc85fec 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -62,7 +62,7 @@ struct HTTPServerTests { } } - _ = try await server.listeningAddress + _ = try await server.listeningAddresses group.cancelAll() } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index aff9b36..ce64962 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -79,7 +79,7 @@ struct NIOHTTPServiceLifecycleTests { let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) group.addTask { try await serviceGroup.run() } - let serverAddress = try await server.listeningAddress + let serverAddress = try await server.listeningAddresses.first! let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) .connectToTestHTTP1Server(at: serverAddress) @@ -165,7 +165,7 @@ struct NIOHTTPServiceLifecycleTests { let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) group.addTask { try await serviceGroup.run() } - let serverAddress = try await server.listeningAddress + let serverAddress = try await server.listeningAddresses.first! let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) .connectToTestHTTP1Server(at: serverAddress) @@ -245,7 +245,7 @@ struct NIOHTTPServiceLifecycleTests { let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) group.addTask { try await serviceGroup.run() } - let serverAddress = try await server.listeningAddress + let serverAddress = try await server.listeningAddresses.first! let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) .connectToTestSecureUpgradeHTTPServer( diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 1e72c47..d71edb4 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -73,6 +73,120 @@ struct NIOHTTPServerSwiftConfigurationTests { } } + @Suite("Multiple bind targets via bindTargets") + struct MultipleBindTargetsTests { + @Test("Parallel hosts and ports produce multiple bind targets") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testMultipleBindTargets() throws { + let provider = InMemoryProvider( + values: [ + "bindTargets.hosts": .init(.stringArray(["127.0.0.1", "::1"]), isSecret: false), + "bindTargets.ports": .init(.intArray([8080, 8443]), isSecret: false), + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.bindTargets.count == 2) + guard case .hostAndPort(let host0, let port0) = serverConfig.bindTargets[0].backing else { + Issue.record("Expected first bind target to be host/port, got \(serverConfig.bindTargets[0].backing)") + return + } + #expect(host0 == "127.0.0.1") + #expect(port0 == 8080) + + guard case .hostAndPort(let host1, let port1) = serverConfig.bindTargets[1].backing else { + Issue.record("Expected second bind target to be host/port, got \(serverConfig.bindTargets[1].backing)") + return + } + #expect(host1 == "::1") + #expect(port1 == 8443) + } + + @Test("Singular bindTarget still works") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testSingularBindTargetStillWorks() throws { + let provider = InMemoryProvider( + values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8080, + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.bindTargets.count == 1) + guard case .hostAndPort(let host, let port) = serverConfig.bindTargets[0].backing else { + Issue.record("Expected host/port, got \(serverConfig.bindTargets[0].backing)") + return + } + #expect(host == "127.0.0.1") + #expect(port == 8080) + } + + @Test("Providing both singular and plural throws an error") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testBothSingularAndPluralThrows() throws { + let provider = InMemoryProvider( + values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8080, + "bindTargets.hosts": .init(.stringArray(["127.0.0.1"]), isSecret: false), + "bindTargets.ports": .init(.intArray([8443]), isSecret: false), + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + #expect(throws: NIOHTTPServerSwiftConfigurationError.singularAndPluralBindTargetsProvided) { + _ = try NIOHTTPServerConfiguration(config: config) + } + } + + @Test("Mismatched hosts and ports lengths throws an error") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testMismatchedHostsAndPortsLengthsThrows() throws { + let provider = InMemoryProvider( + values: [ + "bindTargets.hosts": .init(.stringArray(["127.0.0.1", "::1"]), isSecret: false), + "bindTargets.ports": .init(.intArray([8080]), isSecret: false), + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + #expect(throws: NIOHTTPServerSwiftConfigurationError.bindTargetsHostsAndPortsLengthMismatch) { + _ = try NIOHTTPServerConfiguration(config: config) + } + } + + @Test("Empty bindTargets arrays throws an error") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testEmptyBindTargetsArraysThrows() throws { + let provider = InMemoryProvider( + values: [ + "bindTargets.hosts": .init(.stringArray([]), isSecret: false), + "bindTargets.ports": .init(.intArray([]), isSecret: false), + "http.versions": .init(.stringArray(["http1_1"]), isSecret: false), + "transportSecurity.mode": "plaintext", + ] + ) + let config = ConfigReader(provider: provider) + + #expect(throws: NIOHTTPServerConfigurationError.noBindTargetsSpecified) { + _ = try NIOHTTPServerConfiguration(config: config) + } + } + } + @Suite("BackPressureStrategy") struct BackPressureStrategyTests { @Test("Default values") @@ -688,9 +802,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let serverConfig = try NIOHTTPServerConfiguration(config: config) - guard case .hostAndPort(host: "127.0.0.1", port: 8000) = serverConfig.bindTarget.backing else { + guard let firstBindTarget = serverConfig.bindTargets.first, + case .hostAndPort(host: "127.0.0.1", port: 8000) = firstBindTarget.backing + else { Issue.record( - "Expected bind target to be 127.0.0.1:8000, got \(serverConfig.bindTarget.backing) instead." + "Expected bind target to be 127.0.0.1:8000, got \(serverConfig.bindTargets) instead." ) return } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 2eef788..de5f0cd 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -59,7 +59,7 @@ struct NIOHTTPServerTests { // Now that the server has shut down, try obtaining the listening address. This should result in an error. await #expect(throws: ListeningAddressError.serverClosed) { - try await server.listeningAddress + try await server.listeningAddresses } } @@ -568,6 +568,221 @@ struct NIOHTTPServerTests { } ) } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Bind to multiple addresses") + func testMultipleBindAddresses() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "NIOHTTPServerTests"), + configuration: try .init( + bindTargets: [ + .hostAndPort(host: "127.0.0.1", port: 0), + .hostAndPort(host: "127.0.0.1", port: 0), + ], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in }, + body: { (addresses: [NIOHTTPServer.SocketAddress]) in + #expect(addresses.count == 2) + #expect(addresses[0].port != addresses[1].port) + } + ) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Serve requests on multiple addresses independently") + func testServeOnMultipleAddresses() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "NIOHTTPServerTests"), + configuration: try .init( + bindTargets: [ + .hostAndPort(host: "127.0.0.1", port: 0), + .hostAndPort(host: "127.0.0.1", port: 0), + ], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in + try await Self.echoResponse( + readUpTo: Self.bodyData.readableBytes, + reader: requestReader, + sender: responseSender + ) + }, + body: { (addresses: [NIOHTTPServer.SocketAddress]) in + #expect(addresses.count == 2) + + // Send a request to the first address + let firstClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: addresses[0]) + + try await firstClient.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + try await Self.validateResponse( + inbound, + expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], + expectedBody: [Self.bodyData], + expectedTrailers: Self.trailer + ) + } + + // Send a request to the second address + let secondClient = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: addresses[1]) + + try await secondClient.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + try await Self.validateResponse( + inbound, + expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], + expectedBody: [Self.bodyData], + expectedTrailers: Self.trailer + ) + } + } + ) + } + + /// Verifies the all-or-nothing listening semantics: when the server stops (e.g., due to cancellation), + /// all bound addresses become unavailable simultaneously and ``listeningAddresses`` throws + /// ``ListeningAddressError/serverClosed``. No subset of addresses continues serving after the server + /// has stopped. + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("All addresses stop together and listeningAddresses throws after server stops") + func testAllAddressesStopTogether() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "NIOHTTPServerTests"), + configuration: try .init( + bindTargets: [ + .hostAndPort(host: "127.0.0.1", port: 0), + .hostAndPort(host: "127.0.0.1", port: 0), + ], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + try await Self.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in + try await Self.echoResponse( + readUpTo: Self.bodyData.readableBytes, + reader: requestReader, + sender: responseSender + ) + }, + body: { addresses in + #expect(addresses.count == 2) + + // Verify both addresses are serving + for addr in addresses { + let client = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .connectToTestHTTP1Server(at: addr) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .post, scheme: "http", authority: "", path: "/"))) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + try await Self.validateResponse( + inbound, + expectedHead: [Self.responseHead(status: .ok, for: .http1_1)], + expectedBody: [Self.bodyData], + expectedTrailers: Self.trailer + ) + } + } + } + ) + + // After the server has stopped, listeningAddresses must throw rather than returning stale addresses. + await #expect(throws: ListeningAddressError.serverClosed) { + try await server.listeningAddresses + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Empty bind targets throws error") + func testEmptyBindTargetsThrows() throws { + #expect(throws: NIOHTTPServerConfigurationError.noBindTargetsSpecified) { + try NIOHTTPServerConfiguration( + bindTargets: [], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + } + } + + /// Verifies that when a later bind target fails, any previously-bound listening channels are cleaned up + /// before the error propagates to the caller. Without cleanup, the already-bound sockets would leak and + /// keep their ports occupied even though the server never started serving. + /// + /// The test binds two targets. The second target is configured to fail by pointing at a port that's + /// already in use. We verify `serve` throws an `IOError` with `EADDRINUSE`, and that we can + /// immediately rebind to the first target's port — proving the first channel was closed before the + /// error propagated. + /// + /// We use a specific port for the first target (rather than `port: 0`) so we know what port to rebind + /// to for the verification. The port is below the typical ephemeral range used by `port: 0` + /// allocations on Linux (32768+) and macOS (49152+), so other tests using `port: 0` cannot + /// accidentally be assigned this port by the OS. + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Previously bound channels are closed when a later bind fails") + func testPreviouslyBoundChannelsAreClosedOnPartialBindFailure() async throws { + let firstPort = 30_210 + + // Hold a live listener on an ephemeral port. The server's second bind will conflict with this + // listener and fail with "address already in use". + let occupiedListener = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .bind(host: "127.0.0.1", port: 0) { channel in + channel.eventLoop.makeSucceededFuture(channel) + } + let occupiedPort = try #require(occupiedListener.channel.localAddress?.port) + defer { occupiedListener.channel.close(promise: nil) } + + // Configure a server that binds to [firstPort, occupiedPort]. The first bind should succeed, + // the second should fail with "address already in use", causing cleanup of the first channel. + let server = NIOHTTPServer( + logger: Logger(label: "NIOHTTPServerTests"), + configuration: try .init( + bindTargets: [ + .hostAndPort(host: "127.0.0.1", port: firstPort), + .hostAndPort(host: "127.0.0.1", port: occupiedPort), + ], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + ) + + let error = await #expect(throws: IOError.self) { + try await server.serve( + handler: HTTPServerClosureRequestHandler { _, _, _, _ in } + ) + } + #expect(error?.errnoCode == EADDRINUSE) + + // If the first channel was properly closed, we should be able to bind to firstPort again. + // If it wasn't (i.e., the channel leaked), this bind will fail with "address already in use". + let rebindAttempt = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .bind(host: "127.0.0.1", port: firstPort) { channel in + channel.eventLoop.makeSucceededFuture(channel) + } + try await rebindAttempt.channel.close() + } } extension NIOHTTPServerTests { @@ -693,8 +908,8 @@ extension NIOHTTPServerTests { return HTTPResponse(status: status, headerFields: headers) } - /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with the listening address, - /// then cancels the server task. + /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with the first + /// listening address, then cancels the server task. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) static func withServer( server: NIOHTTPServer, @@ -703,19 +918,34 @@ extension NIOHTTPServerTests { NIOHTTPServer.ResponseConcludingWriter >, body: (NIOHTTPServer.SocketAddress) async throws -> Void + ) async throws { + try await self.withServer(server: server, serverHandler: serverHandler) { + (addresses: [NIOHTTPServer.SocketAddress]) in + let address = try #require(addresses.first) + try await body(address) + } + } + + /// Starts `server` with `serverHandler`, waits for it to begin listening, runs `body` with all listening + /// addresses, then cancels the server task. + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + static func withServer( + server: NIOHTTPServer, + serverHandler: some HTTPServerRequestHandler< + NIOHTTPServer.RequestConcludingReader, + NIOHTTPServer.ResponseConcludingWriter + >, + body: ([NIOHTTPServer.SocketAddress]) async throws -> Void ) async throws { try await withThrowingTaskGroup { group in - // Add the server task to the group group.addTask { try await server.serve(handler: serverHandler) } - // Wait for the server to start listening before running the body closure - let listeningAddress = try await server.listeningAddress + let listeningAddresses = try await server.listeningAddresses - try await body(listeningAddress) + try await body(listeningAddresses) - // Shut the server down group.cancelAll() } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift index bb5a1a6..a698443 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+HTTP1.swift @@ -38,8 +38,8 @@ extension NIOHTTPServer { // Trick the server into thinking it's been bound to an address so that we don't leak the listening address // promise. In reality, the server hasn't been bound to any address: we will manually feed in requests and // observe responses. - try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) - _ = try await self.listeningAddress + try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) + _ = try await self.listeningAddresses try await self.serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift index bfa3ad7..71af635 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/NIOHTTPServer+SecureUpgrade.swift @@ -38,8 +38,8 @@ extension NIOHTTPServer { // Trick the server into thinking it's been bound to an address so that we don't leak the listening address // promise. In reality, the server hasn't been bound to any address: we will manually feed in requests and // observe responses. - try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) - _ = try await self.listeningAddress + try self.addressesBound([.init(ipAddress: "127.0.0.1", port: 8000)]) + _ = try await self.listeningAddresses try await self.serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) }