From e31119420bff44f2a97129bd765e08f6c6300520 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Wed, 13 May 2026 16:14:45 +0100 Subject: [PATCH 1/8] Allow binding multiple addresses --- .../NIOHTTPServerConfiguration.swift | 37 +++- .../NIOHTTPServerConfigurationError.swift | 4 + .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 54 ++--- .../NIOHTTPServer+ListeningAddress.swift | 44 ++-- .../NIOHTTPServer+SecureUpgrade.swift | 50 +++-- Sources/NIOHTTPServer/NIOHTTPServer.swift | 140 +++++++------ .../NIOHTTPServerTests/HTTPServerTests.swift | 2 +- .../NIOHTTPServer+ServiceLifecycleTests.swift | 6 +- ...NIOHTTPServerSwiftConfigurationTests.swift | 6 +- .../NIOHTTPServerTests.swift | 195 +++++++++++++++++- .../NIOHTTPServer+HTTP1.swift | 4 +- .../NIOHTTPServer+SecureUpgrade.swift | 4 +- 12 files changed, 390 insertions(+), 156 deletions(-) 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/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index d8c2f01..5244488 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -62,34 +62,40 @@ 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>]() + 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) + } } + + 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..b740910 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -37,25 +37,25 @@ 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. + /// It is an `async` property because it will only return once the addresses have been successfully bound. /// - /// - Throws: An error will be thrown if the address could not be bound or is not bound any longer because the + /// - 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. - public var listeningAddress: SocketAddress { + public var listeningAddresses: [SocketAddress] { get async throws { try await self.listeningAddressState - .withLockedValue { try $0.listeningAddressFuture } + .withLockedValue { try $0.listeningAddressesFuture } .get() } } @@ -64,11 +64,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 +82,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..119c6c8 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -153,34 +153,40 @@ 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>]() + 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) + } } + + 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..ea36d14 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -116,100 +116,106 @@ 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() + let serverChannels = try await self.makeServerChannels() + + defer { self.finishListeningAddressPromise() } 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 +335,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..7addc2b 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -688,9 +688,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..8cc4241 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,170 @@ 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", .timeLimit(.minutes(1))) + 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 + ) + ) + + // Start the server, verify both addresses are listening, then cancel. + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve( + handler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in + try await Self.echoResponse( + readUpTo: Self.bodyData.readableBytes, + reader: requestReader, + sender: responseSender + ) + } + ) + } + + let addresses = try await server.listeningAddresses + #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 + ) + } + } + + // Stop the server + group.cancelAll() + } + + // 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 + ) + } + } } extension NIOHTTPServerTests { @@ -693,8 +857,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 +867,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) } From a6ab67541d46c377e6f3a5a3cefd35160178e01d Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Wed, 13 May 2026 16:28:14 +0100 Subject: [PATCH 2/8] Format --- Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 4 ++-- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 5244488..fd001f2 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -79,8 +79,8 @@ extension NIOHTTPServer { 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 + let serverChannel = + try await bootstrap.bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( channel: channel, asyncChannelConfiguration: .init( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 119c6c8..ce75b01 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -172,8 +172,8 @@ extension NIOHTTPServer { 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 + let serverChannel = + try await bootstrap.bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, supportedHTTPVersions: supportedHTTPVersions, From b605d91cbee16eed7113ed97174cc95a2428a477 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Thu, 14 May 2026 13:58:27 +0100 Subject: [PATCH 3/8] Remove old test timeout --- Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 8cc4241..d4575f2 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -663,7 +663,7 @@ struct NIOHTTPServerTests { /// ``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", .timeLimit(.minutes(1))) + @Test("All addresses stop together and listeningAddresses throws after server stops") func testAllAddressesStopTogether() async throws { let server = NIOHTTPServer( logger: Logger(label: "NIOHTTPServerTests"), From d28635fe30e76113fe8606cc6eadc318cdee9abd Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Thu, 14 May 2026 14:12:56 +0100 Subject: [PATCH 4/8] Update swift-configuration integration --- .../NIOHTTPServer+SwiftConfiguration.swift | 44 ++++++- ...NIOHTTPServerSwiftConfigurationError.swift | 8 ++ .../SwiftConfigurationIntegration.md | 22 +++- ...NIOHTTPServerSwiftConfigurationTests.swift | 114 ++++++++++++++++++ 4 files changed, 184 insertions(+), 4 deletions(-) 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/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/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 7addc2b..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") From 2347fe189cb0df401628357e7ecaa40dc78ca759 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Thu, 14 May 2026 14:14:09 +0100 Subject: [PATCH 5/8] Improve docs for listeningAddresses --- Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index b740910..8d2b66f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -48,10 +48,12 @@ extension NIOHTTPServer { /// The addresses the server is listening on. /// - /// It is an `async` property because it will only return once the addresses have 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 addresses could not be bound or are not bound any longer because the - /// server isn't listening anymore. + /// server isn't listening anymore (for example, after ``serve(handler:)`` has returned). public var listeningAddresses: [SocketAddress] { get async throws { try await self.listeningAddressState From 4b38661e28dfd45d25899fddc147cc63a46f08c8 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Thu, 14 May 2026 14:29:58 +0100 Subject: [PATCH 6/8] Fix bug where earlier binds would leak channels if later binds failed --- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 34 +++++++---- .../NIOHTTPServer+SecureUpgrade.swift | 32 ++++++---- Sources/NIOHTTPServer/NIOHTTPServer.swift | 6 +- .../NIOHTTPServerTests.swift | 59 +++++++++++++++++++ 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index fd001f2..d4f8c98 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -76,21 +76,29 @@ extension NIOHTTPServer { } var serverChannels = [NIOAsyncChannel, Never>]() - 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 + 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) + } + serverChannels.append(serverChannel) + } + } + } catch { + // A later bind failed: close any channels we already bound to avoid leaking sockets. + for serverChannel in serverChannels { + serverChannel.channel.close(promise: nil) } + throw error } try self.addressesBound(serverChannels.map { $0.channel.localAddress }) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index ce75b01..cd1781f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -169,19 +169,27 @@ extension NIOHTTPServer { } var serverChannels = [NIOAsyncChannel, Never>]() - 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) + 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. + for serverChannel in serverChannels { + serverChannel.channel.close(promise: nil) } + throw error } try self.addressesBound(serverChannels.map { $0.channel.localAddress }) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index ea36d14..a97a443 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -153,10 +153,12 @@ public struct NIOHTTPServer: HTTPServer { public func serve( handler: some HTTPServerRequestHandler ) async throws { - let serverChannels = try await self.makeServerChannels() - + // 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(serverChannels: serverChannels, handler: handler) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index d4575f2..73ed8dc 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -732,6 +732,65 @@ struct NIOHTTPServerTests { ) } } + + /// Verifies that when a later bind target fails, any previously-bound listening channels are cleaned up. + /// 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. After `serve` throws, we verify that the first target's port is free by successfully + /// binding a fresh listener on it. + @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", .timeLimit(.minutes(1))) + func testPreviouslyBoundChannelsAreClosedOnPartialBindFailure() async throws { + // Reserve a port by binding a dummy listener. We'll use this port for the first bind target. + // We close the dummy listener immediately; its port should be free to reuse (SO_REUSEADDR is set + // on server bootstraps). + let dummyForFirstTarget = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .bind(host: "127.0.0.1", port: 0) { channel in + channel.eventLoop.makeSucceededFuture(channel) + } + let firstPort = try #require(dummyForFirstTarget.channel.localAddress?.port) + try await dummyForFirstTarget.channel.close() + + // Bind a live listener that will cause the second bind to fail. + 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". + 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 + ) + ) + + await #expect(throws: Error.self) { + try await server.serve( + handler: HTTPServerClosureRequestHandler { _, _, _, _ in } + ) + } + + // 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. + 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 { From 1726688289a998781bf65fe6fb6e5a5ccfbf3bb6 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 15 May 2026 16:49:14 +0100 Subject: [PATCH 7/8] Fix flaky test --- .../NIOHTTPServerTests.swift | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 73ed8dc..9ac2626 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -740,21 +740,19 @@ struct NIOHTTPServerTests { /// The test binds two targets. The second target is configured to fail by pointing at a port that's /// already in use. After `serve` throws, we verify that the first target's port is free by successfully /// binding a fresh listener on it. + /// @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", .timeLimit(.minutes(1))) + @Test("Previously bound channels are closed when a later bind fails") func testPreviouslyBoundChannelsAreClosedOnPartialBindFailure() async throws { - // Reserve a port by binding a dummy listener. We'll use this port for the first bind target. - // We close the dummy listener immediately; its port should be free to reuse (SO_REUSEADDR is set - // on server bootstraps). - let dummyForFirstTarget = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .bind(host: "127.0.0.1", port: 0) { channel in - channel.eventLoop.makeSucceededFuture(channel) - } - let firstPort = try #require(dummyForFirstTarget.channel.localAddress?.port) - try await dummyForFirstTarget.channel.close() - - // Bind a live listener that will cause the second bind to fail. + // We use a specific port for the first target rather than `port: 0`, because the + // "bind to ephemeral port 0, close, then verify the port is free later" pattern is racy in + // a parallel test environment: another test that binds to port 0 may be assigned the same + // port between the close and our verification. The chosen port is also below the typical + // ephemeral range so it cannot be allocated to other parallel tests using `port: 0`. + 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) @@ -763,7 +761,7 @@ struct NIOHTTPServerTests { 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". + // 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( @@ -776,14 +774,20 @@ struct NIOHTTPServerTests { ) ) - await #expect(throws: Error.self) { + let error = await #expect(throws: IOError.self) { try await server.serve( handler: HTTPServerClosureRequestHandler { _, _, _, _ in } ) } + #expect(error?.errnoCode == EADDRINUSE) + + // The server's cleanup of partially-bound channels uses fire-and-forget close, so the socket + // may not be fully released the instant `serve` throws. Wait briefly so the close has time + // to propagate before we try to rebind. + try await Task.sleep(for: .milliseconds(100)) // 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. + // 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 From aa0f5cf33632055a33e45ab30df7cf2cad3b54cd Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Mon, 18 May 2026 13:26:08 +0100 Subject: [PATCH 8/8] PR changes --- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 4 +- .../NIOHTTPServer+SecureUpgrade.swift | 4 +- .../NIOHTTPServerTests.swift | 84 ++++++++----------- 3 files changed, 42 insertions(+), 50 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index d4f8c98..fbe7a7d 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -95,8 +95,10 @@ extension NIOHTTPServer { } } 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 { - serverChannel.channel.close(promise: nil) + try? await serverChannel.channel.close() } throw error } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index cd1781f..5c24a2f 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -186,8 +186,10 @@ extension NIOHTTPServer { } } 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 { - serverChannel.channel.close(promise: nil) + try? await serverChannel.channel.close() } throw error } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index 9ac2626..de5f0cd 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -677,43 +677,36 @@ struct NIOHTTPServerTests { ) ) - // Start the server, verify both addresses are listening, then cancel. - try await withThrowingTaskGroup { group in - group.addTask { - try await server.serve( - handler: HTTPServerClosureRequestHandler { request, context, requestReader, responseSender in - try await Self.echoResponse( - readUpTo: Self.bodyData.readableBytes, - reader: requestReader, - sender: responseSender - ) - } + 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 ) - } - - let addresses = try await server.listeningAddresses - #expect(addresses.count == 2) + }, + 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 - ) + // 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 + ) + } } } - - // Stop the server - group.cancelAll() - } + ) // After the server has stopped, listeningAddresses must throw rather than returning stale addresses. await #expect(throws: ListeningAddressError.serverClosed) { @@ -733,22 +726,22 @@ struct NIOHTTPServerTests { } } - /// Verifies that when a later bind target fails, any previously-bound listening channels are cleaned up. - /// Without cleanup, the already-bound sockets would leak and keep their ports occupied even though the - /// server never started serving. + /// 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. After `serve` throws, we verify that the first target's port is free by successfully - /// binding a fresh listener on it. + /// 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 { - // We use a specific port for the first target rather than `port: 0`, because the - // "bind to ephemeral port 0, close, then verify the port is free later" pattern is racy in - // a parallel test environment: another test that binds to port 0 may be assigned the same - // port between the close and our verification. The chosen port is also below the typical - // ephemeral range so it cannot be allocated to other parallel tests using `port: 0`. let firstPort = 30_210 // Hold a live listener on an ephemeral port. The server's second bind will conflict with this @@ -781,11 +774,6 @@ struct NIOHTTPServerTests { } #expect(error?.errnoCode == EADDRINUSE) - // The server's cleanup of partially-bound channels uses fire-and-forget close, so the socket - // may not be fully released the instant `serve` throws. Wait briefly so the close has time - // to propagate before we try to rebind. - try await Task.sleep(for: .milliseconds(100)) - // 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)