From 2fb47cc2c2ba542462fde8bc1a94b1961e8e625e Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 27 Mar 2026 11:14:22 +0000 Subject: [PATCH 01/14] Add connection backpressure and timeouts --- .../NIOHTTPServer+SwiftConfiguration.swift | 29 ++- .../NIOHTTPServerConfiguration.swift | 66 +++++- .../NIOHTTPServerConfigurationError.swift | 4 + .../ConnectionLimitHandler.swift | 61 +++++ .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 8 + .../NIOHTTPServer+SecureUpgrade.swift | 16 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 58 +++++ Sources/NIOHTTPServer/TimeoutHandlers.swift | 104 +++++++++ ...ectionBackpressureConfigurationTests.swift | 153 +++++++++++++ .../ConnectionBackpressureEndToEndTests.swift | 214 ++++++++++++++++++ .../ConnectionLimitHandlerTests.swift | 158 +++++++++++++ .../TimeoutHandlerTests.swift | 173 ++++++++++++++ 12 files changed, 1041 insertions(+), 3 deletions(-) create mode 100644 Sources/NIOHTTPServer/ConnectionLimitHandler.swift create mode 100644 Sources/NIOHTTPServer/TimeoutHandlers.swift create mode 100644 Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift create mode 100644 Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift create mode 100644 Tests/NIOHTTPServerTests/ConnectionLimitHandlerTests.swift create mode 100644 Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 241f287..d732f9e 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -65,7 +65,9 @@ extension NIOHTTPServerConfiguration { config: snapshot.scoped(to: "transportSecurity"), customCertificateVerificationCallback: customCertificateVerificationCallback ), - backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")) + backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")), + maxConnections: snapshot.int(forKey: "maxConnections"), + connectionTimeouts: .init(config: snapshot.scoped(to: "connectionTimeouts")) ) } } @@ -446,4 +448,29 @@ extension CertificateVerificationMode { } } } +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.ConnectionTimeouts { + /// Initialize connection timeouts configuration from a config reader. + /// + /// ## Configuration keys: + /// - `idle` (int, optional, default: 60): Maximum time in seconds a connection can remain idle. + /// Set to `null` to disable. + /// - `readHeader` (int, optional, default: 30): Maximum time in seconds to receive request headers. + /// Set to `null` to disable. + /// - `readBody` (int, optional, default: 60): Maximum time in seconds to receive the request body. + /// Set to `null` to disable. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + idle: config.int(forKey: "idle").map { .seconds($0) } + ?? Self.defaultIdle, + readHeader: config.int(forKey: "readHeader").map { .seconds($0) } + ?? Self.defaultReadHeader, + readBody: config.int(forKey: "readBody").map { .seconds($0) } + ?? Self.defaultReadBody + ) + } +} + #endif // Configuration diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index b64f29e..cf0f344 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -230,6 +230,51 @@ public struct NIOHTTPServerConfiguration: Sendable { } } + /// Configuration for connection timeouts. + /// + /// Timeouts are enabled by default with reasonable values to protect against + /// slow or idle connections. Individual timeouts can be disabled by setting + /// them to `nil`. + public struct ConnectionTimeouts: Sendable { + /// Maximum time a connection can remain idle (no data read or written) + /// before being closed. `nil` means no idle timeout. + public var idle: Duration? + + /// Maximum time allowed to receive the complete request headers + /// after a connection is established. `nil` means no timeout. + public var readHeader: Duration? + + /// Maximum time allowed to receive the complete request body + /// after headers have been received. `nil` means no timeout. + public var readBody: Duration? + + /// - Parameters: + /// - idle: Maximum idle time before the connection is closed. + /// - readHeader: Maximum time to receive request headers. + /// - readBody: Maximum time to receive the request body. + public init( + idle: Duration? = Self.defaultIdle, + readHeader: Duration? = Self.defaultReadHeader, + readBody: Duration? = Self.defaultReadBody + ) { + self.idle = idle + self.readHeader = readHeader + self.readBody = readBody + } + + @inlinable + static var defaultIdle: Duration? { .seconds(60) } + + @inlinable + static var defaultReadHeader: Duration? { .seconds(30) } + + @inlinable + static var defaultReadBody: Duration? { .seconds(60) } + + /// Default timeout values: 60s idle, 30s read header, 60s read body. + public static var defaults: Self { .init() } + } + /// Network binding configuration public var bindTarget: BindTarget @@ -242,6 +287,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Backpressure strategy to use in the server. public var backpressureStrategy: BackPressureStrategy + /// The maximum number of concurrent connections the server will accept. + /// + /// When this limit is reached, the server stops accepting new connections + /// until existing ones close. `nil` means unlimited (the default). + public var maxConnections: Int? + + /// Configuration for connection timeouts. + public var connectionTimeouts: ConnectionTimeouts + /// Create a new configuration. /// - Parameters: /// - bindTarget: A ``BindTarget``. @@ -249,11 +303,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - 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. + /// - maxConnections: The maximum number of concurrent connections. `nil` means unlimited. + /// - connectionTimeouts: The connection timeout configuration. public init( bindTarget: BindTarget, supportedHTTPVersions: Set, transportSecurity: TransportSecurity, - backpressureStrategy: BackPressureStrategy = .defaults + backpressureStrategy: BackPressureStrategy = .defaults, + maxConnections: Int? = nil, + connectionTimeouts: ConnectionTimeouts = .defaults ) throws { // 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`. @@ -267,10 +325,16 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } + if let maxConnections, maxConnections <= 0 { + throw NIOHTTPServerConfigurationError.invalidMaxConnections + } + self.bindTarget = bindTarget self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity self.backpressureStrategy = backpressureStrategy + self.maxConnections = maxConnections + self.connectionTimeouts = connectionTimeouts } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 2001f29..4b399cd 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 invalidMaxConnections 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 .invalidMaxConnections: + "Invalid configuration: `maxConnections` must be greater than 0." } } } diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift new file mode 100644 index 0000000..1427706 --- /dev/null +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore + +/// A channel handler installed on the server (parent) channel that limits the +/// number of concurrent connections by gating `read()` calls. +/// +/// When the number of active connections reaches `maxConnections`, this handler +/// stops forwarding `read()` events, which prevents NIO from calling `accept()` +/// on the listening socket. When a connection closes and count drops below the +/// limit, `read()` is re-triggered to resume accepting. +final class ConnectionLimitHandler: ChannelDuplexHandler { + typealias InboundIn = Channel + typealias InboundOut = Channel + typealias OutboundIn = Channel + + private let maxConnections: Int + private var activeConnections: Int = 0 + + init(maxConnections: Int) { + self.maxConnections = maxConnections + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let childChannel = self.unwrapInboundIn(data) + self.activeConnections += 1 + + let loopBoundSelf = NIOLoopBound(self, eventLoop: context.eventLoop) + let channel = context.channel + let eventLoop = context.eventLoop + childChannel.closeFuture.whenComplete { _ in + eventLoop.execute { + let `self` = loopBoundSelf.value + self.activeConnections -= 1 + if self.activeConnections < self.maxConnections { + channel.read() + } + } + } + + context.fireChannelRead(data) + } + + func read(context: ChannelHandlerContext) { + if self.activeConnections <= self.maxConnections { + context.read() + } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 8cd4a0d..6f4dd65 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -52,6 +52,12 @@ extension NIOHTTPServer { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } } } .bind(host: host, port: port) { channel in @@ -77,6 +83,8 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) + try self.addTimeoutHandlers(to: channel) + return try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: asyncChannelConfiguration diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 65657f7..737db45 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -97,6 +97,12 @@ extension NIOHTTPServer { try channel.pipeline.syncOperations.addHandler( self.serverQuiescingHelper.makeServerChannelHandler(channel: channel) ) + + if let maxConnections = self.configuration.maxConnections { + try channel.pipeline.syncOperations.addHandler( + ConnectionLimitHandler(maxConnections: maxConnections) + ) + } } } .bind(host: host, port: port) { channel in @@ -120,6 +126,8 @@ extension NIOHTTPServer { channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) + try self.addTimeoutHandlers(to: channel) + return try NIOAsyncChannel( wrappingChannelSynchronously: channel, configuration: .init( @@ -141,7 +149,10 @@ extension NIOHTTPServer { ) > { channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + // Add idle timeout at the connection level for HTTP/2 + try self.addIdleTimeoutHandlers(to: channel) + + return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, connectionManagerConfiguration: .init( maxIdleTime: nil, @@ -158,6 +169,9 @@ extension NIOHTTPServer { HTTP2FramePayloadToHTTPServerCodec() ) + // Add read header and body timeouts per-stream for HTTP/2 + try self.addReadTimeoutHandlers(to: http2StreamChannel) + return try NIOAsyncChannel( wrappingChannelSynchronously: http2StreamChannel, configuration: .init( diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 6e7e5e0..be36d77 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -322,6 +322,64 @@ public struct NIOHTTPServer: HTTPServer { secureUpgradeChannel.channel.close(promise: nil) } } + + /// Adds timeout handlers (idle, read header, read body) to a child channel pipeline. + /// + /// Only handlers for non-nil timeouts are installed. This is called for both + /// HTTP/1.1 per-connection channels and HTTP/2 per-stream channels. + func addTimeoutHandlers(to channel: any Channel) throws { + let timeouts = self.configuration.connectionTimeouts + + if let idle = timeouts.idle { + let idleTimeAmount = TimeAmount(idle) + try channel.pipeline.syncOperations.addHandler( + IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) + ) + try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) + } + + if let readHeader = timeouts.readHeader { + try channel.pipeline.syncOperations.addHandler( + ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) + ) + } + + if let readBody = timeouts.readBody { + try channel.pipeline.syncOperations.addHandler( + ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) + ) + } + } + + /// Adds only idle timeout handlers to a channel. Used for HTTP/2 connection-level channels + /// where read header/body timeouts are handled per-stream. + func addIdleTimeoutHandlers(to channel: any Channel) throws { + if let idle = self.configuration.connectionTimeouts.idle { + let idleTimeAmount = TimeAmount(idle) + try channel.pipeline.syncOperations.addHandler( + IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) + ) + try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) + } + } + + /// Adds only read header and body timeout handlers to a channel. Used for HTTP/2 per-stream + /// channels where idle timeout is handled at the connection level. + func addReadTimeoutHandlers(to channel: any Channel) throws { + let timeouts = self.configuration.connectionTimeouts + + if let readHeader = timeouts.readHeader { + try channel.pipeline.syncOperations.addHandler( + ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) + ) + } + + if let readBody = timeouts.readBody { + try channel.pipeline.syncOperations.addHandler( + ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) + ) + } + } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift new file mode 100644 index 0000000..8b081b6 --- /dev/null +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTPTypes + +/// A channel handler that closes the connection if the complete request headers +/// are not received within the configured timeout. +/// +/// The timeout starts when the channel becomes active and is cancelled when +/// a `.head` part is received. +final class ReadHeaderTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPRequestPart + + private let timeout: TimeAmount + private var scheduledTimeout: Scheduled? + + init(timeout: TimeAmount) { + self.timeout = timeout + } + + func channelActive(context: ChannelHandlerContext) { + self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { + context.close(promise: nil) + } + context.fireChannelActive() + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = self.unwrapInboundIn(data) + if case .head = part { + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } + context.fireChannelRead(data) + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } +} + +/// A channel handler that closes the connection if the complete request body +/// is not received within the configured timeout after headers are received. +/// +/// The timeout starts when a `.head` part is received and is cancelled when +/// an `.end` part is received. Intermediate `.body` parts do not reset the timer. +final class ReadBodyTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPRequestPart + + private let timeout: TimeAmount + private var scheduledTimeout: Scheduled? + + init(timeout: TimeAmount) { + self.timeout = timeout + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = self.unwrapInboundIn(data) + switch part { + case .head: + self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { + context.close(promise: nil) + } + case .end: + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + case .body: + break + } + context.fireChannelRead(data) + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } +} + +/// A channel handler that closes the connection when an idle state event is +/// received from an upstream `IdleStateHandler`. +final class ConnectionIdleHandler: ChannelInboundHandler { + typealias InboundIn = NIOAny + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if event is IdleStateHandler.IdleStateEvent { + context.close(promise: nil) + } else { + context.fireUserInboundEventTriggered(event) + } + } +} diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift new file mode 100644 index 0000000..3c48fd6 --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Testing + +@testable import NIOHTTPServer + +@Suite("Connection Backpressure Configuration") +struct ConnectionBackpressureConfigurationTests { + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections validation rejects zero") + func maxConnectionsRejectsZero() { + #expect(throws: NIOHTTPServerConfigurationError.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 0 + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections validation rejects negative") + func maxConnectionsRejectsNegative() { + #expect(throws: NIOHTTPServerConfigurationError.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: -1 + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("maxConnections nil is the default") + func maxConnectionsNilIsDefault() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + #expect(config.maxConnections == nil) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("ConnectionTimeouts defaults has expected values") + func connectionTimeoutsDefaults() { + let timeouts = NIOHTTPServerConfiguration.ConnectionTimeouts.defaults + #expect(timeouts.idle == .seconds(60)) + #expect(timeouts.readHeader == .seconds(30)) + #expect(timeouts.readBody == .seconds(60)) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Valid maxConnections is accepted") + func validMaxConnectionsAccepted() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 100 + ) + #expect(config.maxConnections == 100) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Custom ConnectionTimeouts are preserved") + func customConnectionTimeouts() throws { + let config = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + connectionTimeouts: .init(idle: .seconds(10), readHeader: .seconds(5), readBody: nil) + ) + #expect(config.connectionTimeouts.idle == .seconds(10)) + #expect(config.connectionTimeouts.readHeader == .seconds(5)) + #expect(config.connectionTimeouts.readBody == nil) + } +} + +#if Configuration +import Configuration + +@Suite("Connection Backpressure SwiftConfiguration") +struct ConnectionBackpressureSwiftConfigurationTests { + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration parses maxConnections") + func parsesMaxConnections() 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", + "maxConnections": 500, + ]) + let config = ConfigReader(provider: provider) + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.maxConnections == 500) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration parses connectionTimeouts") + func parsesConnectionTimeouts() 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", + "connectionTimeouts.idle": 120, + "connectionTimeouts.readHeader": 15, + "connectionTimeouts.readBody": 45, + ]) + let config = ConfigReader(provider: provider) + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + #expect(serverConfig.connectionTimeouts.idle == .seconds(120)) + #expect(serverConfig.connectionTimeouts.readHeader == .seconds(15)) + #expect(serverConfig.connectionTimeouts.readBody == .seconds(45)) + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("SwiftConfiguration uses defaults for absent fields") + func usesDefaultsForAbsentFields() 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.maxConnections == nil) + #expect(serverConfig.connectionTimeouts.idle == .seconds(60)) + #expect(serverConfig.connectionTimeouts.readHeader == .seconds(30)) + #expect(serverConfig.connectionTimeouts.readBody == .seconds(60)) + } +} +#endif // Configuration diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift new file mode 100644 index 0000000..a9de836 --- /dev/null +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -0,0 +1,214 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPAPIs +import Logging +import NIOCore +import NIOPosix +import Synchronization +import Testing + +@testable import NIOHTTPServer + +@Suite("Connection Backpressure End-to-End") +struct ConnectionBackpressureEndToEndTests { + let serverLogger = Logger(label: "ConnectionBackpressureE2ETests") + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("Requests succeed under connection limit") + func requestsSucceedUnderConnectionLimit() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 2, + connectionTimeouts: .init(idle: nil, readHeader: nil, readBody: nil) + ) + ) + + try await confirmation(expectedCount: 2) { responseReceived in + try await NIOHTTPServerTests.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in + _ = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + return try await bodyReader.collect(upTo: 1024) { _ in } + } + let writer = try await responseSender.send(.init(status: .ok)) + try await writer.produceAndConclude { bodyWriter in nil } + }, + body: { serverAddress in + await withThrowingTaskGroup { group in + for _ in 0..<2 { + group.addTask { + let client = try await ClientBootstrap( + group: .singletonMultiThreadedEventLoopGroup + ).connectToTestHTTP1Server(at: serverAddress) + + try await client.executeThenClose { inbound, outbound in + try await outbound.write( + .head(.init(method: .get, scheme: "http", authority: "", path: "/")) + ) + try await outbound.write(.end(nil)) + + var iter = inbound.makeAsyncIterator() + let head = try await iter.next() + guard case .head(let response) = head else { + Issue.record("Expected response head") + return + } + #expect(response.status == 200) + + // Read remaining parts + while let part = try await iter.next() { + if case .end = part { break } + } + + responseReceived() + } + } + } + } + } + ) + } + } + + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + @Test("More connections than maxConnections all eventually complete") + func moreConnectionsThanLimitAllComplete() async throws { + let server = NIOHTTPServer( + logger: self.serverLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext, + maxConnections: 2, + connectionTimeouts: .init(idle: nil, readHeader: nil, readBody: nil) + ) + ) + + // Open 5 connections with maxConnections: 2. All should eventually complete + // as the connection limit handler releases slots when connections close. + let numConnections = 5 + try await confirmation(expectedCount: numConnections) { responseReceived in + try await NIOHTTPServerTests.withServer( + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in + _ = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + return try await bodyReader.collect(upTo: 1024) { _ in } + } + let writer = try await responseSender.send(.init(status: .ok)) + try await writer.produceAndConclude { bodyWriter in nil } + }, + body: { serverAddress in + await withThrowingTaskGroup { group in + for _ in 0.. Date: Mon, 30 Mar 2026 15:35:15 +0100 Subject: [PATCH 02/14] Fix warnings --- Sources/NIOHTTPServer/TimeoutHandlers.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index 8b081b6..975483e 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -31,8 +31,9 @@ final class ReadHeaderTimeoutHandler: ChannelInboundHandler, RemovableChannelHan } func channelActive(context: ChannelHandlerContext) { + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { - context.close(promise: nil) + boundContext.value.close(promise: nil) } context.fireChannelActive() } @@ -71,8 +72,9 @@ final class ReadBodyTimeoutHandler: ChannelInboundHandler, RemovableChannelHandl let part = self.unwrapInboundIn(data) switch part { case .head: + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { - context.close(promise: nil) + boundContext.value.close(promise: nil) } case .end: self.scheduledTimeout?.cancel() From 7569b3b0320cb800c6840c6d8b6eb092e955ab94 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 31 Mar 2026 13:11:08 +0100 Subject: [PATCH 03/14] Fix small bugs in ConnectionLimitHandler --- Sources/NIOHTTPServer/ConnectionLimitHandler.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift index 1427706..d463170 100644 --- a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -44,8 +44,8 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { eventLoop.execute { let `self` = loopBoundSelf.value self.activeConnections -= 1 - if self.activeConnections < self.maxConnections { - channel.read() + if self.activeConnections <= self.maxConnections { + context.read() } } } From 12ebb341f92871c8af92ba0f959ebc8cfe1e52e9 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 31 Mar 2026 13:13:36 +0100 Subject: [PATCH 04/14] Fix warnings --- .../NIOHTTPServer/ConnectionLimitHandler.swift | 3 ++- .../TimeoutHandlerTests.swift | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift index d463170..3dbcd25 100644 --- a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -38,11 +38,12 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { self.activeConnections += 1 let loopBoundSelf = NIOLoopBound(self, eventLoop: context.eventLoop) - let channel = context.channel + let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) let eventLoop = context.eventLoop childChannel.closeFuture.whenComplete { _ in eventLoop.execute { let `self` = loopBoundSelf.value + let context = loopBoundContext.value self.activeConnections -= 1 if self.activeConnections <= self.maxConnections { context.read() diff --git a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift index adc3bda..2a9913b 100644 --- a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift @@ -24,7 +24,7 @@ import Testing struct ReadHeaderTimeoutHandlerTests { @Test("Headers received within timeout — connection stays open") - func headersReceivedWithinTimeout() async throws { + func headersReceivedWithinTimeout() throws { let channel = EmbeddedChannel() let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -44,7 +44,7 @@ struct ReadHeaderTimeoutHandlerTests { } @Test("Headers not received within timeout — connection closed") - func headersNotReceivedWithinTimeout() async throws { + func headersNotReceivedWithinTimeout() throws { let channel = EmbeddedChannel() let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -60,7 +60,7 @@ struct ReadHeaderTimeoutHandlerTests { } @Test("Cleanup on handler removal") - func cleanupOnHandlerRemoval() async throws { + func cleanupOnHandlerRemoval() throws { let channel = EmbeddedChannel() let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -69,7 +69,7 @@ struct ReadHeaderTimeoutHandlerTests { try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() // Remove the handler before the timeout fires - try channel.pipeline.syncOperations.removeHandler(handler) + _ = channel.pipeline.syncOperations.removeHandler(handler) // Advance past the timeout channel.embeddedEventLoop.advanceTime(by: .seconds(10)) @@ -83,7 +83,7 @@ struct ReadHeaderTimeoutHandlerTests { struct ReadBodyTimeoutHandlerTests { @Test("Body completed within timeout — connection stays open") - func bodyCompletedWithinTimeout() async throws { + func bodyCompletedWithinTimeout() throws { let channel = EmbeddedChannel() let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -105,7 +105,7 @@ struct ReadBodyTimeoutHandlerTests { } @Test("Body not completed within timeout — connection closed") - func bodyNotCompletedWithinTimeout() async throws { + func bodyNotCompletedWithinTimeout() throws { let channel = EmbeddedChannel() let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -124,7 +124,7 @@ struct ReadBodyTimeoutHandlerTests { } @Test("Body parts do not reset timeout") - func bodyPartsDoNotResetTimeout() async throws { + func bodyPartsDoNotResetTimeout() throws { let channel = EmbeddedChannel() let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -150,7 +150,7 @@ struct ReadBodyTimeoutHandlerTests { } @Test("Cleanup on handler removal") - func cleanupOnHandlerRemoval() async throws { + func cleanupOnHandlerRemoval() throws { let channel = EmbeddedChannel() let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) @@ -162,7 +162,7 @@ struct ReadBodyTimeoutHandlerTests { try channel.writeInbound(HTTPRequestPart.head(head)) // Remove handler before timeout - try channel.pipeline.syncOperations.removeHandler(handler) + _ = channel.pipeline.syncOperations.removeHandler(handler) // Advance past timeout channel.embeddedEventLoop.advanceTime(by: .seconds(10)) From 49444646490a8852743f984dc8ead06b87db3799 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 11:20:19 +0100 Subject: [PATCH 05/14] Don't coalesce SwiftConfiguration keys --- .../NIOHTTPServer+SwiftConfiguration.swift | 16 +++++----------- .../SwiftConfigurationIntegration.md | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index d732f9e..02c7878 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -453,22 +453,16 @@ extension NIOHTTPServerConfiguration.ConnectionTimeouts { /// Initialize connection timeouts configuration from a config reader. /// /// ## Configuration keys: - /// - `idle` (int, optional, default: 60): Maximum time in seconds a connection can remain idle. - /// Set to `null` to disable. - /// - `readHeader` (int, optional, default: 30): Maximum time in seconds to receive request headers. - /// Set to `null` to disable. - /// - `readBody` (int, optional, default: 60): Maximum time in seconds to receive the request body. - /// Set to `null` to disable. + /// - `idle` (int, optional, default: nil): Maximum time in seconds a connection can remain idle. + /// - `readHeader` (int, optional, default: nil): Maximum time in seconds to receive request headers. + /// - `readBody` (int, optional, default: nil): Maximum time in seconds to receive the request body. /// /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) { self.init( - idle: config.int(forKey: "idle").map { .seconds($0) } - ?? Self.defaultIdle, - readHeader: config.int(forKey: "readHeader").map { .seconds($0) } - ?? Self.defaultReadHeader, + idle: config.int(forKey: "idle").map { .seconds($0) }, + readHeader: config.int(forKey: "readHeader").map { .seconds($0) }, readBody: config.int(forKey: "readBody").map { .seconds($0) } - ?? Self.defaultReadBody ) } } diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 005fce3..f071eaf 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -29,8 +29,8 @@ let serverConfiguration = try NIOHTTPServerConfiguration(config: config) ### Configuration key reference -``NIOHTTPServerConfiguration`` is comprised of four components. Provide the configuration for each component under its -respective key prefix. +``NIOHTTPServerConfiguration`` is comprised of several components. Provide the configuration for each component under +its respective key prefix. > 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"`. @@ -57,6 +57,10 @@ respective key prefix. | | `certificateVerificationMode` | `string` | Required for `"mTLS"`, permitted values: `"optionalVerification"`, `"noHostnameVerification"` | - | | `backpressureStrategy` | `lowWatermark` | `int` | Optional | 2 | | | `highWatermark` | `int` | Optional | 10 | +| - | `maxConnections` | `int` | Optional | nil | +| `connectionTimeouts` | `idle` | `int` | Optional | nil | +| | `readHeader` | `int` | Optional | nil | +| | `readBody` | `int` | Optional | nil | The `credentialSource` determines how server credentials are provided: @@ -108,6 +112,12 @@ key were omitted. "backpressureStrategy": { "lowWatermark": 2, // default: 2 "highWatermark": 10 // default: 10 + }, + "maxConnections": 1000, // default: nil (unlimited) + "connectionTimeouts": { + "idle": 60, // default: nil (no timeout) + "readHeader": 30, // default: nil (no timeout) + "readBody": 60 // default: nil (no timeout) } } ``` From 92ea289269eb66cb5d5f980ce03e7f901162b807 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 13:42:45 +0100 Subject: [PATCH 06/14] Move timeout configuration funcs to Channel extension --- .../NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 2 +- .../NIOHTTPServer+SecureUpgrade.swift | 6 +-- Sources/NIOHTTPServer/NIOHTTPServer.swift | 51 +++++++------------ 3 files changed, 21 insertions(+), 38 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 6f4dd65..72c485a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -83,7 +83,7 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) - try self.addTimeoutHandlers(to: channel) + try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: channel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 737db45..650235c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -126,7 +126,7 @@ extension NIOHTTPServer { channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) - try self.addTimeoutHandlers(to: channel) + try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: channel, @@ -150,7 +150,7 @@ extension NIOHTTPServer { > { channel.eventLoop.makeCompletedFuture { // Add idle timeout at the connection level for HTTP/2 - try self.addIdleTimeoutHandlers(to: channel) + try channel.addIdleTimeoutHandlers(self.configuration.connectionTimeouts) return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, @@ -170,7 +170,7 @@ extension NIOHTTPServer { ) // Add read header and body timeouts per-stream for HTTP/2 - try self.addReadTimeoutHandlers(to: http2StreamChannel) + try http2StreamChannel.addReadTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: http2StreamChannel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index be36d77..fd7e323 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -323,59 +323,42 @@ public struct NIOHTTPServer: HTTPServer { } } - /// Adds timeout handlers (idle, read header, read body) to a child channel pipeline. +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Channel { + /// Adds timeout handlers (idle, read header, read body) to the channel pipeline. /// /// Only handlers for non-nil timeouts are installed. This is called for both /// HTTP/1.1 per-connection channels and HTTP/2 per-stream channels. - func addTimeoutHandlers(to channel: any Channel) throws { - let timeouts = self.configuration.connectionTimeouts - - if let idle = timeouts.idle { - let idleTimeAmount = TimeAmount(idle) - try channel.pipeline.syncOperations.addHandler( - IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) - ) - try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) - } - - if let readHeader = timeouts.readHeader { - try channel.pipeline.syncOperations.addHandler( - ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) - ) - } - - if let readBody = timeouts.readBody { - try channel.pipeline.syncOperations.addHandler( - ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) - ) - } + func addTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { + try self.addIdleTimeoutHandlers(timeouts) + try self.addReadTimeoutHandlers(timeouts) } - /// Adds only idle timeout handlers to a channel. Used for HTTP/2 connection-level channels + /// Adds only idle timeout handlers to the channel. Used for HTTP/2 connection-level channels /// where read header/body timeouts are handled per-stream. - func addIdleTimeoutHandlers(to channel: any Channel) throws { - if let idle = self.configuration.connectionTimeouts.idle { + func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { + if let idle = timeouts.idle { let idleTimeAmount = TimeAmount(idle) - try channel.pipeline.syncOperations.addHandler( + try self.pipeline.syncOperations.addHandler( IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) ) - try channel.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) + try self.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) } } - /// Adds only read header and body timeout handlers to a channel. Used for HTTP/2 per-stream + /// Adds only read header and body timeout handlers to the channel. Used for HTTP/2 per-stream /// channels where idle timeout is handled at the connection level. - func addReadTimeoutHandlers(to channel: any Channel) throws { - let timeouts = self.configuration.connectionTimeouts - + func addReadTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { if let readHeader = timeouts.readHeader { - try channel.pipeline.syncOperations.addHandler( + try self.pipeline.syncOperations.addHandler( ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) ) } if let readBody = timeouts.readBody { - try channel.pipeline.syncOperations.addHandler( + try self.pipeline.syncOperations.addHandler( ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) ) } From 42f3ecfbce579a780208591a76245bad5328e3d0 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:03:23 +0100 Subject: [PATCH 07/14] Consolidate timeout handlers --- Sources/NIOHTTPServer/NIOHTTPServer.swift | 16 +- Sources/NIOHTTPServer/TimeoutHandlers.swift | 98 +++++---- .../TimeoutHandlerTests.swift | 199 +++++++++++++----- 3 files changed, 216 insertions(+), 97 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index fd7e323..f7c78d0 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -340,26 +340,20 @@ extension Channel { /// where read header/body timeouts are handled per-stream. func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { if let idle = timeouts.idle { - let idleTimeAmount = TimeAmount(idle) try self.pipeline.syncOperations.addHandler( - IdleStateHandler(readTimeout: idleTimeAmount, writeTimeout: idleTimeAmount) + ConnectionIdleTimeoutHandler(timeout: TimeAmount(idle)) ) - try self.pipeline.syncOperations.addHandler(ConnectionIdleHandler()) } } /// Adds only read header and body timeout handlers to the channel. Used for HTTP/2 per-stream /// channels where idle timeout is handled at the connection level. func addReadTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { - if let readHeader = timeouts.readHeader { + let readHeader = timeouts.readHeader.map { TimeAmount($0) } + let readBody = timeouts.readBody.map { TimeAmount($0) } + if readHeader != nil || readBody != nil { try self.pipeline.syncOperations.addHandler( - ReadHeaderTimeoutHandler(timeout: TimeAmount(readHeader)) - ) - } - - if let readBody = timeouts.readBody { - try self.pipeline.syncOperations.addHandler( - ReadBodyTimeoutHandler(timeout: TimeAmount(readBody)) + RequestTimeoutHandler(readHeaderTimeout: readHeader, readBodyTimeout: readBody) ) } } diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index 975483e..47bfaf3 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -15,13 +15,19 @@ import NIOCore import NIOHTTPTypes -/// A channel handler that closes the connection if the complete request headers -/// are not received within the configured timeout. +/// A channel handler that closes the connection after a period of inactivity. /// -/// The timeout starts when the channel becomes active and is cancelled when -/// a `.head` part is received. -final class ReadHeaderTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { - typealias InboundIn = HTTPRequestPart +/// The timeout is scheduled when the channel becomes active and is rescheduled +/// whenever a read or write occurs. If the timeout fires without any activity, +/// the connection is closed. +/// +/// This replaces the combination of NIO's `IdleStateHandler` and a separate +/// handler to react to idle events. +final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler { + typealias InboundIn = NIOAny + typealias InboundOut = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny private let timeout: TimeAmount private var scheduledTimeout: Scheduled? @@ -31,56 +37,77 @@ final class ReadHeaderTimeoutHandler: ChannelInboundHandler, RemovableChannelHan } func channelActive(context: ChannelHandlerContext) { - let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) - self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { - boundContext.value.close(promise: nil) - } + self.scheduleTimeout(context: context) context.fireChannelActive() } func channelRead(context: ChannelHandlerContext, data: NIOAny) { - let part = self.unwrapInboundIn(data) - if case .head = part { - self.scheduledTimeout?.cancel() - self.scheduledTimeout = nil - } + self.scheduleTimeout(context: context) context.fireChannelRead(data) } + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + self.scheduleTimeout(context: context) + context.write(data, promise: promise) + } + func handlerRemoved(context: ChannelHandlerContext) { self.scheduledTimeout?.cancel() self.scheduledTimeout = nil } + + private func scheduleTimeout(context: ChannelHandlerContext) { + self.scheduledTimeout?.cancel() + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { + boundContext.value.close(promise: nil) + } + } } -/// A channel handler that closes the connection if the complete request body -/// is not received within the configured timeout after headers are received. +/// A channel handler that enforces timeouts on receiving request headers and body. /// -/// The timeout starts when a `.head` part is received and is cancelled when -/// an `.end` part is received. Intermediate `.body` parts do not reset the timer. -final class ReadBodyTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { +/// This combines header and body read timeouts into a single handler with a +/// state machine: +/// - On channel active, a header timeout is scheduled (if configured). +/// - When `.head` is received, the header timeout is cancelled and a body +/// timeout is scheduled (if configured). +/// - When `.end` is received, the body timeout is cancelled. +/// +/// If either timeout fires, the connection is closed. +final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { typealias InboundIn = HTTPRequestPart - private let timeout: TimeAmount + private let readHeaderTimeout: TimeAmount? + private let readBodyTimeout: TimeAmount? private var scheduledTimeout: Scheduled? - init(timeout: TimeAmount) { - self.timeout = timeout + init(readHeaderTimeout: TimeAmount?, readBodyTimeout: TimeAmount?) { + self.readHeaderTimeout = readHeaderTimeout + self.readBodyTimeout = readBodyTimeout + } + + func channelActive(context: ChannelHandlerContext) { + if let readHeaderTimeout { + self.scheduleTimeout(readHeaderTimeout, context: context) + } + context.fireChannelActive() } func channelRead(context: ChannelHandlerContext, data: NIOAny) { let part = self.unwrapInboundIn(data) switch part { case .head: - let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) - self.scheduledTimeout = context.eventLoop.scheduleTask(in: self.timeout) { - boundContext.value.close(promise: nil) - } - case .end: self.scheduledTimeout?.cancel() self.scheduledTimeout = nil + if let readBodyTimeout { + self.scheduleTimeout(readBodyTimeout, context: context) + } case .body: break + case .end: + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil } context.fireChannelRead(data) } @@ -89,18 +116,11 @@ final class ReadBodyTimeoutHandler: ChannelInboundHandler, RemovableChannelHandl self.scheduledTimeout?.cancel() self.scheduledTimeout = nil } -} - -/// A channel handler that closes the connection when an idle state event is -/// received from an upstream `IdleStateHandler`. -final class ConnectionIdleHandler: ChannelInboundHandler { - typealias InboundIn = NIOAny - func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - if event is IdleStateHandler.IdleStateEvent { - context.close(promise: nil) - } else { - context.fireUserInboundEventTriggered(event) + private func scheduleTimeout(_ timeout: TimeAmount, context: ChannelHandlerContext) { + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + self.scheduledTimeout = context.eventLoop.scheduleTask(in: timeout) { + boundContext.value.close(promise: nil) } } } diff --git a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift index 2a9913b..cab7d22 100644 --- a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift @@ -20,154 +20,259 @@ import Testing @testable import NIOHTTPServer -@Suite("ReadHeaderTimeoutHandler") -struct ReadHeaderTimeoutHandlerTests { +@Suite("ConnectionIdleTimeoutHandler") +struct ConnectionIdleTimeoutHandlerTests { - @Test("Headers received within timeout — connection stays open") - func headersReceivedWithinTimeout() throws { + @Test("Connection closed after idle timeout") + func closedAfterIdleTimeout() throws { let channel = EmbeddedChannel() - let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) + let handler = ConnectionIdleTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) - // Activate the channel (starts the timer) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Send headers before the timeout - let head = HTTPRequest(method: .get, scheme: "http", authority: "", path: "/") - try channel.writeInbound(HTTPRequestPart.head(head)) + // Advance past the timeout with no activity + channel.embeddedEventLoop.advanceTime(by: .seconds(6)) - // Advance past the timeout - channel.embeddedEventLoop.advanceTime(by: .seconds(10)) + #expect(!channel.isActive) + } + + @Test("Read resets idle timeout") + func readResetsTimeout() throws { + let channel = EmbeddedChannel() + let handler = ConnectionIdleTimeoutHandler(timeout: .seconds(5)) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Channel should still be active + // Advance partway, then trigger a read + channel.embeddedEventLoop.advanceTime(by: .seconds(4)) + try channel.writeInbound(ByteBuffer(bytes: [1, 2, 3])) + + // Advance past the original timeout but within the reset timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(4)) #expect(channel.isActive) + + // Now advance past the reset timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(2)) + #expect(!channel.isActive) } - @Test("Headers not received within timeout — connection closed") - func headersNotReceivedWithinTimeout() throws { + @Test("Write resets idle timeout") + func writeResetsTimeout() throws { let channel = EmbeddedChannel() - let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) + let handler = ConnectionIdleTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) - // Activate the channel (starts the timer) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Don't send any headers, advance past timeout - channel.embeddedEventLoop.advanceTime(by: .seconds(6)) + // Advance partway, then trigger a write + channel.embeddedEventLoop.advanceTime(by: .seconds(4)) + try channel.writeOutbound(ByteBuffer(bytes: [1, 2, 3])) - // Channel should be closed + // Advance past the original timeout but within the reset timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(4)) + #expect(channel.isActive) + + // Now advance past the reset timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(2)) #expect(!channel.isActive) } @Test("Cleanup on handler removal") func cleanupOnHandlerRemoval() throws { let channel = EmbeddedChannel() - let handler = ReadHeaderTimeoutHandler(timeout: .seconds(5)) + let handler = ConnectionIdleTimeoutHandler(timeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) - // Activate the channel (starts the timer) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Remove the handler before the timeout fires _ = channel.pipeline.syncOperations.removeHandler(handler) - // Advance past the timeout channel.embeddedEventLoop.advanceTime(by: .seconds(10)) - // Channel should still be active — the scheduled task was cancelled on removal #expect(channel.isActive) } } -@Suite("ReadBodyTimeoutHandler") -struct ReadBodyTimeoutHandlerTests { +@Suite("RequestTimeoutHandler") +struct RequestTimeoutHandlerTests { + + // MARK: - Header timeout tests + + @Test("Headers received within timeout — connection stays open") + func headersReceivedWithinTimeout() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: nil) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + let head = HTTPRequest(method: .get, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + + channel.embeddedEventLoop.advanceTime(by: .seconds(10)) + + #expect(channel.isActive) + } + + @Test("Headers not received within timeout — connection closed") + func headersNotReceivedWithinTimeout() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: nil) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + channel.embeddedEventLoop.advanceTime(by: .seconds(6)) + + #expect(!channel.isActive) + } + + // MARK: - Body timeout tests @Test("Body completed within timeout — connection stays open") func bodyCompletedWithinTimeout() throws { let channel = EmbeddedChannel() - let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) + let handler = RequestTimeoutHandler(readHeaderTimeout: nil, readBodyTimeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Send head (starts the timer) let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") try channel.writeInbound(HTTPRequestPart.head(head)) - // Send end before timeout try channel.writeInbound(HTTPRequestPart.end(nil)) - // Advance past timeout channel.embeddedEventLoop.advanceTime(by: .seconds(10)) - // Channel should still be active #expect(channel.isActive) } @Test("Body not completed within timeout — connection closed") func bodyNotCompletedWithinTimeout() throws { let channel = EmbeddedChannel() - let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) + let handler = RequestTimeoutHandler(readHeaderTimeout: nil, readBodyTimeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Send head (starts the timer) but don't send end let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") try channel.writeInbound(HTTPRequestPart.head(head)) - // Advance past timeout without sending end channel.embeddedEventLoop.advanceTime(by: .seconds(6)) - // Channel should be closed #expect(!channel.isActive) } @Test("Body parts do not reset timeout") func bodyPartsDoNotResetTimeout() throws { let channel = EmbeddedChannel() - let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) + let handler = RequestTimeoutHandler(readHeaderTimeout: nil, readBodyTimeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Send head (starts the timer) let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") try channel.writeInbound(HTTPRequestPart.head(head)) - // Send body chunks at intervals — these should NOT reset the timer channel.embeddedEventLoop.advanceTime(by: .seconds(2)) try channel.writeInbound(HTTPRequestPart.body(ByteBuffer(bytes: [1, 2, 3]))) channel.embeddedEventLoop.advanceTime(by: .seconds(2)) try channel.writeInbound(HTTPRequestPart.body(ByteBuffer(bytes: [4, 5, 6]))) - // Now advance past the original 5s timeout (total 6s since head) + // Total 6s since head — past the 5s timeout channel.embeddedEventLoop.advanceTime(by: .seconds(2)) - // Channel should be closed — body chunks didn't reset the timer #expect(!channel.isActive) } - @Test("Cleanup on handler removal") - func cleanupOnHandlerRemoval() throws { + // MARK: - Combined timeout tests + + @Test("Both timeouts configured — header then body") + func bothTimeoutsHeaderThenBody() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: .seconds(10)) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + // Send head within header timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(3)) + let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + + // Send end within body timeout + channel.embeddedEventLoop.advanceTime(by: .seconds(8)) + try channel.writeInbound(HTTPRequestPart.end(nil)) + + channel.embeddedEventLoop.advanceTime(by: .seconds(20)) + + #expect(channel.isActive) + } + + @Test("Both timeouts configured — header timeout fires") + func bothTimeoutsHeaderFires() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: .seconds(10)) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + channel.embeddedEventLoop.advanceTime(by: .seconds(6)) + + #expect(!channel.isActive) + } + + @Test("Both timeouts configured — body timeout fires") + func bothTimeoutsBodyFires() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: .seconds(10)) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + + channel.embeddedEventLoop.advanceTime(by: .seconds(11)) + + #expect(!channel.isActive) + } + + // MARK: - Cleanup + + @Test("Cleanup on handler removal during header phase") + func cleanupOnHandlerRemovalDuringHeaderPhase() throws { + let channel = EmbeddedChannel() + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: .seconds(5)) + try channel.pipeline.syncOperations.addHandler(handler) + + try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() + + _ = channel.pipeline.syncOperations.removeHandler(handler) + + channel.embeddedEventLoop.advanceTime(by: .seconds(10)) + + #expect(channel.isActive) + } + + @Test("Cleanup on handler removal during body phase") + func cleanupOnHandlerRemovalDuringBodyPhase() throws { let channel = EmbeddedChannel() - let handler = ReadBodyTimeoutHandler(timeout: .seconds(5)) + let handler = RequestTimeoutHandler(readHeaderTimeout: .seconds(5), readBodyTimeout: .seconds(5)) try channel.pipeline.syncOperations.addHandler(handler) try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Send head (starts the timer) let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") try channel.writeInbound(HTTPRequestPart.head(head)) - // Remove handler before timeout _ = channel.pipeline.syncOperations.removeHandler(handler) - // Advance past timeout channel.embeddedEventLoop.advanceTime(by: .seconds(10)) - // Channel should still be active #expect(channel.isActive) } } From 676da34f96ace647f0efc1eb546d2b06092ef607 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:03:31 +0100 Subject: [PATCH 08/14] Avoid unnecessary EL hop --- Sources/NIOHTTPServer/ConnectionLimitHandler.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift index 3dbcd25..27f688d 100644 --- a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -41,13 +41,11 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) let eventLoop = context.eventLoop childChannel.closeFuture.whenComplete { _ in - eventLoop.execute { - let `self` = loopBoundSelf.value - let context = loopBoundContext.value - self.activeConnections -= 1 - if self.activeConnections <= self.maxConnections { - context.read() - } + let `self` = loopBoundSelf.value + let context = loopBoundContext.value + `self`.activeConnections -= 1 + if `self`.activeConnections <= `self`.maxConnections { + context.read() } } From 26ee0d391d9db0b54131392822ae94eb7acba6b2 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:09:51 +0100 Subject: [PATCH 09/14] Only forward read on ConnectionLimitHandler if there was a read pending --- Sources/NIOHTTPServer/ConnectionLimitHandler.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift index 27f688d..e7dd25b 100644 --- a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -28,6 +28,7 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { private let maxConnections: Int private var activeConnections: Int = 0 + private var pendingRead: Bool = false init(maxConnections: Int) { self.maxConnections = maxConnections @@ -39,12 +40,12 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { let loopBoundSelf = NIOLoopBound(self, eventLoop: context.eventLoop) let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) - let eventLoop = context.eventLoop childChannel.closeFuture.whenComplete { _ in let `self` = loopBoundSelf.value let context = loopBoundContext.value `self`.activeConnections -= 1 - if `self`.activeConnections <= `self`.maxConnections { + if `self`.pendingRead && `self`.activeConnections <= `self`.maxConnections { + `self`.pendingRead = false context.read() } } @@ -55,6 +56,8 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { func read(context: ChannelHandlerContext) { if self.activeConnections <= self.maxConnections { context.read() + } else { + self.pendingRead = true } } } From 4495e5baa28167e5ceaf2189cdfacc5dc9163c99 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:28:26 +0100 Subject: [PATCH 10/14] EL hop was actually necessary --- .../NIOHTTPServer/ConnectionLimitHandler.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift index e7dd25b..afb47f2 100644 --- a/Sources/NIOHTTPServer/ConnectionLimitHandler.swift +++ b/Sources/NIOHTTPServer/ConnectionLimitHandler.swift @@ -40,13 +40,16 @@ final class ConnectionLimitHandler: ChannelDuplexHandler { let loopBoundSelf = NIOLoopBound(self, eventLoop: context.eventLoop) let loopBoundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + let eventLoop = context.eventLoop childChannel.closeFuture.whenComplete { _ in - let `self` = loopBoundSelf.value - let context = loopBoundContext.value - `self`.activeConnections -= 1 - if `self`.pendingRead && `self`.activeConnections <= `self`.maxConnections { - `self`.pendingRead = false - context.read() + eventLoop.execute { + let `self` = loopBoundSelf.value + let context = loopBoundContext.value + `self`.activeConnections -= 1 + if `self`.pendingRead && `self`.activeConnections <= `self`.maxConnections { + `self`.pendingRead = false + context.read() + } } } From 0a312ab992b37a54ec29518f66898dec1ac7e392 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:28:37 +0100 Subject: [PATCH 11/14] Remove some duplication in tests --- .../ConnectionBackpressureEndToEndTests.swift | 82 +++++++------------ 1 file changed, 30 insertions(+), 52 deletions(-) diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift index a9de836..cdd70c7 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -43,12 +43,11 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in - _ = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: 1024) { _ in } - } - let writer = try await responseSender.send(.init(status: .ok)) - try await writer.produceAndConclude { bodyWriter in nil } + try await NIOHTTPServerTests.echoResponse( + readUpTo: 1024, + reader: reader, + sender: responseSender + ) }, body: { serverAddress in await withThrowingTaskGroup { group in @@ -64,18 +63,11 @@ struct ConnectionBackpressureEndToEndTests { ) try await outbound.write(.end(nil)) - var iter = inbound.makeAsyncIterator() - let head = try await iter.next() - guard case .head(let response) = head else { - Issue.record("Expected response head") - return - } - #expect(response.status == 200) - - // Read remaining parts - while let part = try await iter.next() { - if case .end = part { break } - } + try await NIOHTTPServerTests.validateResponse( + inbound, + expectedHead: [.init(status: .ok)], + expectedBody: [] + ) responseReceived() } @@ -108,12 +100,11 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in - _ = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: 1024) { _ in } - } - let writer = try await responseSender.send(.init(status: .ok)) - try await writer.produceAndConclude { bodyWriter in nil } + try await NIOHTTPServerTests.echoResponse( + readUpTo: 1024, + reader: reader, + sender: responseSender + ) }, body: { serverAddress in await withThrowingTaskGroup { group in @@ -129,17 +120,11 @@ struct ConnectionBackpressureEndToEndTests { ) try await outbound.write(.end(nil)) - var iter = inbound.makeAsyncIterator() - let head = try await iter.next() - guard case .head(let response) = head else { - Issue.record("Expected response head") - return - } - #expect(response.status == 200) - - while let part = try await iter.next() { - if case .end = part { break } - } + try await NIOHTTPServerTests.validateResponse( + inbound, + expectedHead: [.init(status: .ok)], + expectedBody: [] + ) responseReceived() } @@ -169,12 +154,11 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.withServer( server: server, serverHandler: HTTPServerClosureRequestHandler { _, _, reader, responseSender in - _ = try await reader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - return try await bodyReader.collect(upTo: 1024) { _ in } - } - let writer = try await responseSender.send(.init(status: .ok)) - try await writer.produceAndConclude { bodyWriter in nil } + try await NIOHTTPServerTests.echoResponse( + readUpTo: 1024, + reader: reader, + sender: responseSender + ) }, body: { serverAddress in await withThrowingTaskGroup { group in @@ -190,17 +174,11 @@ struct ConnectionBackpressureEndToEndTests { ) try await outbound.write(.end(nil)) - var iter = inbound.makeAsyncIterator() - let head = try await iter.next() - guard case .head(let response) = head else { - Issue.record("Expected response head") - return - } - #expect(response.status == 200) - - while let part = try await iter.next() { - if case .end = part { break } - } + try await NIOHTTPServerTests.validateResponse( + inbound, + expectedHead: [.init(status: .ok)], + expectedBody: [] + ) responseReceived() } From 6915d493eb629338ed39c26d024be8fe30519681 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:28:46 +0100 Subject: [PATCH 12/14] Add missing conformance to handler --- Sources/NIOHTTPServer/TimeoutHandlers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index 47bfaf3..6998e65 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -23,7 +23,7 @@ import NIOHTTPTypes /// /// This replaces the combination of NIO's `IdleStateHandler` and a separate /// handler to react to idle events. -final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler { +final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannelHandler { typealias InboundIn = NIOAny typealias InboundOut = NIOAny typealias OutboundIn = NIOAny From 3aeb28d38fff9f2f844b88ef4ea8b9fc639194aa Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:39:22 +0100 Subject: [PATCH 13/14] Fix SwiftConfiguration tests --- .../ConnectionBackpressureConfigurationTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift index 3c48fd6..f29a4c7 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift @@ -145,9 +145,9 @@ struct ConnectionBackpressureSwiftConfigurationTests { let serverConfig = try NIOHTTPServerConfiguration(config: config) #expect(serverConfig.maxConnections == nil) - #expect(serverConfig.connectionTimeouts.idle == .seconds(60)) - #expect(serverConfig.connectionTimeouts.readHeader == .seconds(30)) - #expect(serverConfig.connectionTimeouts.readBody == .seconds(60)) + #expect(serverConfig.connectionTimeouts.idle == nil) + #expect(serverConfig.connectionTimeouts.readHeader == nil) + #expect(serverConfig.connectionTimeouts.readBody == nil) } } #endif // Configuration From 0a86d08c5ab5eef0f163ae0a432436397fad8a88 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Tue, 14 Apr 2026 16:47:46 +0100 Subject: [PATCH 14/14] Fix E2E tests --- .../ConnectionBackpressureEndToEndTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift index cdd70c7..34330f4 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -65,7 +65,7 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.validateResponse( inbound, - expectedHead: [.init(status: .ok)], + expectedHead: [NIOHTTPServerTests.responseHead(status: .ok, for: .http1_1)], expectedBody: [] ) @@ -122,7 +122,7 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.validateResponse( inbound, - expectedHead: [.init(status: .ok)], + expectedHead: [NIOHTTPServerTests.responseHead(status: .ok, for: .http1_1)], expectedBody: [] ) @@ -176,7 +176,7 @@ struct ConnectionBackpressureEndToEndTests { try await NIOHTTPServerTests.validateResponse( inbound, - expectedHead: [.init(status: .ok)], + expectedHead: [NIOHTTPServerTests.responseHead(status: .ok, for: .http1_1)], expectedBody: [] )