From 2fb47cc2c2ba542462fde8bc1a94b1961e8e625e Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 27 Mar 2026 11:14:22 +0000 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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: [] ) From 0634fee905e96ee3060bc096249dab54836aaa1a Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Wed, 6 May 2026 14:54:39 +0100 Subject: [PATCH 15/20] PR changes --- .../NIOHTTPServer+SwiftConfiguration.swift | 6 ++++-- .../NIOHTTPServerConfiguration.swift | 6 +++--- Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift | 5 ++++- .../NIOHTTPServer+SecureUpgrade.swift | 15 ++++++++++++--- Sources/NIOHTTPServer/NIOHTTPServer.swift | 6 +++--- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 02c7878..bc67348 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -454,8 +454,10 @@ extension NIOHTTPServerConfiguration.ConnectionTimeouts { /// /// ## Configuration keys: /// - `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. + /// - `readHeader` (int, optional, default: nil): Maximum time in seconds to receive request headers + /// after a connection is established. + /// - `readBody` (int, optional, default: nil): Maximum time in seconds to receive the complete request + /// body after headers have been received. /// /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) { diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index cf0f344..b9b81d6 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -236,7 +236,7 @@ public struct NIOHTTPServerConfiguration: Sendable { /// 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) + /// Maximum time an established connection can remain idle (no data read or written) /// before being closed. `nil` means no idle timeout. public var idle: Duration? @@ -250,8 +250,8 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - 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. + /// - readHeader: Maximum time to receive request headers after a connection is established. + /// - readBody: Maximum time to receive the complete request body after headers have been received. public init( idle: Duration? = Self.defaultIdle, readHeader: Duration? = Self.defaultReadHeader, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 72c485a..aa9c7b2 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -83,7 +83,10 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) - try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) + try channel + .pipeline + .syncOperations + .addTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: channel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 650235c..f5201cc 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -126,7 +126,10 @@ extension NIOHTTPServer { channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) - try channel.addTimeoutHandlers(self.configuration.connectionTimeouts) + try channel + .pipeline + .syncOperations + .addTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: channel, @@ -150,7 +153,10 @@ extension NIOHTTPServer { > { channel.eventLoop.makeCompletedFuture { // Add idle timeout at the connection level for HTTP/2 - try channel.addIdleTimeoutHandlers(self.configuration.connectionTimeouts) + try channel + .pipeline + .syncOperations + .addIdleTimeoutHandlers(self.configuration.connectionTimeouts) return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, @@ -170,7 +176,10 @@ extension NIOHTTPServer { ) // Add read header and body timeouts per-stream for HTTP/2 - try http2StreamChannel.addReadTimeoutHandlers(self.configuration.connectionTimeouts) + try http2StreamChannel + .pipeline + .syncOperations + .addReadTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: http2StreamChannel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index f7c78d0..d7aba5a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -326,7 +326,7 @@ public struct NIOHTTPServer: HTTPServer { } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -extension Channel { +extension ChannelPipeline.SynchronousOperations { /// 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 @@ -340,7 +340,7 @@ extension Channel { /// where read header/body timeouts are handled per-stream. func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { if let idle = timeouts.idle { - try self.pipeline.syncOperations.addHandler( + try self.addHandler( ConnectionIdleTimeoutHandler(timeout: TimeAmount(idle)) ) } @@ -352,7 +352,7 @@ extension Channel { let readHeader = timeouts.readHeader.map { TimeAmount($0) } let readBody = timeouts.readBody.map { TimeAmount($0) } if readHeader != nil || readBody != nil { - try self.pipeline.syncOperations.addHandler( + try self.addHandler( RequestTimeoutHandler(readHeaderTimeout: readHeader, readBodyTimeout: readBody) ) } From f50ce330d214e72d0018ce8419aa17e452771a52 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 29 May 2026 16:00:24 +0100 Subject: [PATCH 16/20] Remove init --- .../NIOHTTPServer+SwiftConfiguration.swift | 8 +-- .../NIOHTTPServerConfiguration.swift | 53 +++++++++---------- .../NIOHTTPServerConfigurationError.swift | 4 -- ...ectionBackpressureConfigurationTests.swift | 38 +++---------- .../ConnectionBackpressureEndToEndTests.swift | 46 +++++++++------- 5 files changed, 61 insertions(+), 88 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 515675c..807ef45 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -73,11 +73,11 @@ extension NIOHTTPServerConfiguration { transportSecurity: try .init( config: snapshot.scoped(to: "transportSecurity"), customCertificateVerificationCallback: customCertificateVerificationCallback - ), - backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")), - maxConnections: snapshot.int(forKey: "maxConnections"), - connectionTimeouts: .init(config: snapshot.scoped(to: "connectionTimeouts")) + ) ) + self.backpressureStrategy = .init(config: snapshot.scoped(to: "backpressureStrategy")) + self.maxConnections = snapshot.int(forKey: "maxConnections") + self.connectionTimeouts = .init(config: snapshot.scoped(to: "connectionTimeouts")) } /// Reads bind targets from either the singular `bindTarget` scope or the plural `bindTargets` scope. diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 51701b4..ac3552f 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -291,27 +291,33 @@ public struct NIOHTTPServerConfiguration: Sendable { /// /// When this limit is reached, the server stops accepting new connections /// until existing ones close. `nil` means unlimited (the default). - public var maxConnections: Int? + /// + /// - Precondition: Must be greater than 0 if non-`nil`. + public var maxConnections: Int? { + didSet { + if let maxConnections, maxConnections <= 0 { + preconditionFailure("`maxConnections` must be greater than 0.") + } + } + } /// Configuration for connection timeouts. public var connectionTimeouts: ConnectionTimeouts /// Create a new configuration with multiple bind targets. + /// + /// Other configuration properties (``backpressureStrategy``, ``maxConnections``, + /// ``connectionTimeouts``) are initialized to their defaults and can be set on the resulting + /// value before passing it to ``NIOHTTPServer``. + /// /// - Parameters: /// - 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. - /// - maxConnections: The maximum number of concurrent connections. `nil` means unlimited. - /// - connectionTimeouts: The connection timeout configuration. public init( bindTargets: [BindTarget], supportedHTTPVersions: Set, - transportSecurity: TransportSecurity, - backpressureStrategy: BackPressureStrategy = .defaults, - maxConnections: Int? = nil, - connectionTimeouts: ConnectionTimeouts = .defaults + transportSecurity: TransportSecurity ) throws { if bindTargets.isEmpty { throw NIOHTTPServerConfigurationError.noBindTargetsSpecified @@ -329,42 +335,33 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } - if let maxConnections, maxConnections <= 0 { - throw NIOHTTPServerConfigurationError.invalidMaxConnections - } - self.bindTargets = bindTargets self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity - self.backpressureStrategy = backpressureStrategy - self.maxConnections = maxConnections - self.connectionTimeouts = connectionTimeouts + self.backpressureStrategy = .defaults + self.maxConnections = nil + self.connectionTimeouts = .defaults } /// Create a new configuration with a single bind target. + /// + /// Other configuration properties (``backpressureStrategy``, ``maxConnections``, + /// ``connectionTimeouts``) are initialized to their defaults and can be set on the resulting + /// value before passing it to ``NIOHTTPServer``. + /// /// - 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. - /// - 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, - maxConnections: Int? = nil, - connectionTimeouts: ConnectionTimeouts = .defaults + transportSecurity: TransportSecurity ) throws { try self.init( bindTargets: [bindTarget], supportedHTTPVersions: supportedHTTPVersions, - transportSecurity: transportSecurity, - backpressureStrategy: backpressureStrategy, - maxConnections: maxConnections, - connectionTimeouts: connectionTimeouts + transportSecurity: transportSecurity ) } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 133cdf6..7f56f9c 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -16,7 +16,6 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case noSupportedHTTPVersionsSpecified case incompatibleTransportSecurity - case invalidMaxConnections case noBindTargetsSpecified var description: String { @@ -27,9 +26,6 @@ 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." - case .noBindTargetsSpecified: "Invalid configuration: at least one bind target must be specified." } diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift index ec2d7a7..3904424 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureConfigurationTests.swift @@ -18,32 +18,6 @@ import Testing @Suite("Connection Backpressure Configuration") struct ConnectionBackpressureConfigurationTests { - @available(anyAppleOS 26.0, *) - @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(anyAppleOS 26.0, *) - @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(anyAppleOS 26.0, *) @Test("maxConnections nil is the default") func maxConnectionsNilIsDefault() throws { @@ -67,24 +41,24 @@ struct ConnectionBackpressureConfigurationTests { @available(anyAppleOS 26.0, *) @Test("Valid maxConnections is accepted") func validMaxConnectionsAccepted() throws { - let config = try NIOHTTPServerConfiguration( + var config = try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext, - maxConnections: 100 + transportSecurity: .plaintext ) + config.maxConnections = 100 #expect(config.maxConnections == 100) } @available(anyAppleOS 26.0, *) @Test("Custom ConnectionTimeouts are preserved") func customConnectionTimeouts() throws { - let config = try NIOHTTPServerConfiguration( + var 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) + transportSecurity: .plaintext ) + config.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) diff --git a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift index 086f0eb..3e7579c 100644 --- a/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionBackpressureEndToEndTests.swift @@ -28,15 +28,17 @@ struct ConnectionBackpressureEndToEndTests { @available(anyAppleOS 26.0, *) @Test("Requests succeed under connection limit") func requestsSucceedUnderConnectionLimit() async throws { + var configuration = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + configuration.maxConnections = 2 + configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) + 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) - ) + configuration: configuration ) try await confirmation(expectedCount: 2) { responseReceived in @@ -85,15 +87,17 @@ struct ConnectionBackpressureEndToEndTests { @available(anyAppleOS 26.0, *) @Test("More connections than maxConnections all eventually complete") func moreConnectionsThanLimitAllComplete() async throws { + var configuration = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + configuration.maxConnections = 2 + configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) + 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) - ) + configuration: configuration ) // Open 5 connections with maxConnections: 2. All should eventually complete @@ -143,14 +147,16 @@ struct ConnectionBackpressureEndToEndTests { @available(anyAppleOS 26.0, *) @Test("No connection limit by default") func noConnectionLimitByDefault() async throws { + var configuration = try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + configuration.connectionTimeouts = .init(idle: nil, readHeader: nil, readBody: nil) + let server = NIOHTTPServer( logger: self.serverLogger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .plaintext, - connectionTimeouts: .init(idle: nil, readHeader: nil, readBody: nil) - ) + configuration: configuration ) let numConnections = 5 From 125845e4bf65a4aced73eee3f09cbbed5f136338 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 29 May 2026 16:04:39 +0100 Subject: [PATCH 17/20] Use assumeIsolated --- Sources/NIOHTTPServer/TimeoutHandlers.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index 6998e65..14bc37f 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -58,9 +58,8 @@ final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannel 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) + self.scheduledTimeout = context.eventLoop.assumeIsolated().scheduleTask(in: self.timeout) { + context.close(promise: nil) } } } @@ -118,9 +117,8 @@ final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandle } 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) + self.scheduledTimeout = context.eventLoop.assumeIsolated().scheduleTask(in: timeout) { + context.close(promise: nil) } } } From 1208826f930cc28d0376c59af7af50be16467198 Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 29 May 2026 16:05:47 +0100 Subject: [PATCH 18/20] Fix md file --- .../Documentation.docc/SwiftConfigurationIntegration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 32e656e..77be261 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -62,7 +62,7 @@ its 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 | +| | `maxConnections` | `int` | Optional | nil | | `connectionTimeouts` | `idle` | `int` | Optional | nil | | | `readHeader` | `int` | Optional | nil | | | `readBody` | `int` | Optional | nil | From 8722118a0e2a2b170414127021da1904983db16a Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 19 Jun 2026 14:28:03 +0100 Subject: [PATCH 19/20] Consolidate some timeouts --- .../NIOHTTPServerConfiguration.swift | 13 ++- .../NIOHTTPServer+SecureUpgrade.swift | 8 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 12 +- Sources/NIOHTTPServer/TimeoutHandlers.swift | 53 ++++++--- .../TimeoutHandlerTests.swift | 108 ++++++++++++++---- 5 files changed, 140 insertions(+), 54 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index ac3552f..2f462e3 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -236,8 +236,17 @@ public struct NIOHTTPServerConfiguration: Sendable { /// slow or idle connections. Individual timeouts can be disabled by setting /// them to `nil`. public struct ConnectionTimeouts: Sendable { - /// Maximum time an established connection can remain idle (no data read or written) - /// before being closed. `nil` means no idle timeout. + /// Maximum time the connection may sit with no request in flight before being closed. + /// + /// On HTTP/1.1, the timer runs between requests on a keep-alive connection: it starts + /// when the connection becomes active and is rescheduled after each response `.end` is + /// written. The timer is cancelled when an inbound request `.head` is observed. + /// + /// On HTTP/2, this is delegated to `NIOHTTP2ServerConnectionManagementHandler`'s + /// `maxIdleTime`, which fires when no streams have been open for the configured duration + /// and triggers a graceful shutdown. + /// + /// `nil` means no idle timeout. public var idle: Duration? /// Maximum time allowed to receive the complete request headers diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 2db5140..852ef0d 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -215,16 +215,10 @@ extension NIOHTTPServer { ) > { channel.eventLoop.makeCompletedFuture { - // Add idle timeout at the connection level for HTTP/2 - try channel - .pipeline - .syncOperations - .addIdleTimeoutHandlers(self.configuration.connectionTimeouts) - return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, connectionManagerConfiguration: .init( - maxIdleTime: nil, + maxIdleTime: self.configuration.connectionTimeouts.idle.map { TimeAmount($0) }, maxAge: nil, maxGraceTime: configuration.gracefulShutdown.maximumGracefulShutdownDuration .map { TimeAmount($0) }, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 70e21a6..9a5bcf3 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -329,15 +329,15 @@ public struct NIOHTTPServer: HTTPServer { extension ChannelPipeline.SynchronousOperations { /// 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. + /// Only handlers for non-nil timeouts are installed. Called for HTTP/1.1 connection channels. func addTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { try self.addIdleTimeoutHandlers(timeouts) try self.addReadTimeoutHandlers(timeouts) } - /// Adds only idle timeout handlers to the channel. Used for HTTP/2 connection-level channels - /// where read header/body timeouts are handled per-stream. + /// Adds the connection idle timeout handler to the channel. Used by HTTP/1.1 connection + /// channels. (HTTP/2 delegates idle handling to `NIOHTTP2ServerConnectionManagementHandler`'s + /// `maxIdleTime`, which is stream-aware.) func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { if let idle = timeouts.idle { try self.addHandler( @@ -346,8 +346,8 @@ extension ChannelPipeline.SynchronousOperations { } } - /// 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. + /// Adds only read header and body timeout handlers to the channel. Used for HTTP/1.1 + /// connection channels and HTTP/2 per-stream channels. func addReadTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { let readHeader = timeouts.readHeader.map { TimeAmount($0) } let readBody = timeouts.readBody.map { TimeAmount($0) } diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index 14bc37f..8bc9178 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -15,19 +15,22 @@ import NIOCore import NIOHTTPTypes -/// A channel handler that closes the connection after a period of inactivity. +/// A channel handler that closes an HTTP/1.1 connection after a period in which no request is in +/// flight. /// -/// 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. +/// The timer runs only between requests: it is scheduled when the channel becomes active and +/// after each response `.end` is written. It is cancelled when an inbound request `.head` is +/// observed. While a request is being processed, request-level timeouts (see +/// ``RequestTimeoutHandler``) are responsible for protecting the server. /// -/// This replaces the combination of NIO's `IdleStateHandler` and a separate -/// handler to react to idle events. +/// This handler is used on the per-connection channel for HTTP/1.1 only. For HTTP/2, idle +/// behaviour is delegated to `NIOHTTP2ServerConnectionManagementHandler`'s `maxIdleTime`, which +/// already understands stream lifecycle. final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannelHandler { - typealias InboundIn = NIOAny - typealias InboundOut = NIOAny - typealias OutboundIn = NIOAny - typealias OutboundOut = NIOAny + typealias InboundIn = HTTPRequestPart + typealias InboundOut = HTTPRequestPart + typealias OutboundIn = HTTPResponsePart + typealias OutboundOut = HTTPResponsePart private let timeout: TimeAmount private var scheduledTimeout: Scheduled? @@ -37,18 +40,28 @@ final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannel } func channelActive(context: ChannelHandlerContext) { + // Connection just opened, no request yet — start the idle timer. self.scheduleTimeout(context: context) context.fireChannelActive() } func channelRead(context: ChannelHandlerContext, data: NIOAny) { - self.scheduleTimeout(context: context) + let part = self.unwrapInboundIn(data) + if case .head = part { + // A request just started; pause idle until the response is fully written. + self.scheduledTimeout?.cancel() + self.scheduledTimeout = nil + } context.fireChannelRead(data) } func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - self.scheduleTimeout(context: context) + let part = self.unwrapOutboundIn(data) context.write(data, promise: promise) + if case .end = part { + // The response is complete; the connection is now between requests, so re-arm idle. + self.scheduleTimeout(context: context) + } } func handlerRemoved(context: ChannelHandlerContext) { @@ -66,12 +79,14 @@ final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannel /// A channel handler that enforces timeouts on receiving request headers and body. /// -/// This combines header and body read timeouts into a single handler with a -/// state machine: +/// 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. +/// - 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 and the header timeout is rescheduled +/// so that the next request on a keep-alive connection is also protected. (For HTTP/2 streams +/// this is a no-op in practice: each stream sees only one request and is closed shortly after +/// `.end`.) /// /// If either timeout fires, the connection is closed. final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { @@ -107,6 +122,10 @@ final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandle case .end: self.scheduledTimeout?.cancel() self.scheduledTimeout = nil + // Re-arm the header timer so the next request on this connection is also protected. + if let readHeaderTimeout { + self.scheduleTimeout(readHeaderTimeout, context: context) + } } context.fireChannelRead(data) } diff --git a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift index cab7d22..b521bf3 100644 --- a/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift +++ b/Tests/NIOHTTPServerTests/TimeoutHandlerTests.swift @@ -23,7 +23,7 @@ import Testing @Suite("ConnectionIdleTimeoutHandler") struct ConnectionIdleTimeoutHandlerTests { - @Test("Connection closed after idle timeout") + @Test("Connection closed after idle timeout with no request") func closedAfterIdleTimeout() throws { let channel = EmbeddedChannel() let handler = ConnectionIdleTimeoutHandler(timeout: .seconds(5)) @@ -31,52 +31,90 @@ struct ConnectionIdleTimeoutHandlerTests { try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 8080)).wait() - // Advance past the timeout with no activity + // Advance past the timeout with no request in flight channel.embeddedEventLoop.advanceTime(by: .seconds(6)) #expect(!channel.isActive) } - @Test("Read resets idle timeout") - func readResetsTimeout() throws { + @Test("Idle timer is cancelled while a request is in flight") + func idleCancelledWhileRequestInFlight() 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() - // Advance partway, then trigger a read - channel.embeddedEventLoop.advanceTime(by: .seconds(4)) - try channel.writeInbound(ByteBuffer(bytes: [1, 2, 3])) + // Send a request head before the idle timeout would fire. + let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + + // Advance well past the original idle window. Idle should not fire because a request is + // in flight (response not yet written). + channel.embeddedEventLoop.advanceTime(by: .seconds(60)) - // 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)) + @Test("Body parts do not reset idle (because idle is paused)") + func bodyPartsDoNotMatter() 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() + + let head = HTTPRequest(method: .post, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + try channel.writeInbound(HTTPRequestPart.body(ByteBuffer(bytes: [1, 2, 3]))) + + // Idle is paused while a request is in flight; it doesn't matter that we got body bytes. + channel.embeddedEventLoop.advanceTime(by: .seconds(10)) + #expect(channel.isActive) + } + + @Test("Idle timer is rearmed after response end (between requests)") + func idleRearmedAfterResponseEnd() 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() + + // Process a request fully. + let head = HTTPRequest(method: .get, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + try channel.writeInbound(HTTPRequestPart.end(nil)) + try channel.writeOutbound(HTTPResponsePart.head(HTTPResponse(status: .ok))) + try channel.writeOutbound(HTTPResponsePart.end(nil)) + + // No new request — advance past the idle window. Connection should close. + channel.embeddedEventLoop.advanceTime(by: .seconds(6)) #expect(!channel.isActive) } - @Test("Write resets idle timeout") - func writeResetsTimeout() throws { + @Test("Idle timer is cancelled when next request begins on a keep-alive connection") + func idleCancelledOnNextRequest() 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() - // Advance partway, then trigger a write - channel.embeddedEventLoop.advanceTime(by: .seconds(4)) - try channel.writeOutbound(ByteBuffer(bytes: [1, 2, 3])) + // First request/response cycle. + let head = HTTPRequest(method: .get, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + try channel.writeInbound(HTTPRequestPart.end(nil)) + try channel.writeOutbound(HTTPResponsePart.head(HTTPResponse(status: .ok))) + try channel.writeOutbound(HTTPResponsePart.end(nil)) - // Advance past the original timeout but within the reset timeout + // Wait partway, then start a second request before idle fires. channel.embeddedEventLoop.advanceTime(by: .seconds(4)) - #expect(channel.isActive) + try channel.writeInbound(HTTPRequestPart.head(head)) - // Now advance past the reset timeout - channel.embeddedEventLoop.advanceTime(by: .seconds(2)) - #expect(!channel.isActive) + // Advance well past — idle should be paused again. + channel.embeddedEventLoop.advanceTime(by: .seconds(60)) + #expect(channel.isActive) } @Test("Cleanup on handler removal") @@ -207,7 +245,14 @@ struct RequestTimeoutHandlerTests { channel.embeddedEventLoop.advanceTime(by: .seconds(8)) try channel.writeInbound(HTTPRequestPart.end(nil)) - channel.embeddedEventLoop.advanceTime(by: .seconds(20)) + // Right after `.end`, the header timeout is re-armed for the next request. Send a fresh + // head before that timer fires so the connection stays open. + channel.embeddedEventLoop.advanceTime(by: .seconds(3)) + try channel.writeInbound(HTTPRequestPart.head(head)) + + // Body timer is now ticking on the second request — finish within the body timeout. + channel.embeddedEventLoop.advanceTime(by: .seconds(5)) + try channel.writeInbound(HTTPRequestPart.end(nil)) #expect(channel.isActive) } @@ -241,6 +286,25 @@ struct RequestTimeoutHandlerTests { #expect(!channel.isActive) } + @Test("Header timeout is re-armed after end so subsequent requests are protected") + func headerTimeoutRearmedAfterEnd() 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() + + // First request completes successfully within the header timeout window. + let head = HTTPRequest(method: .get, scheme: "http", authority: "", path: "/") + try channel.writeInbound(HTTPRequestPart.head(head)) + try channel.writeInbound(HTTPRequestPart.end(nil)) + + // Now no second request arrives within the header timeout — connection should be closed. + channel.embeddedEventLoop.advanceTime(by: .seconds(6)) + + #expect(!channel.isActive) + } + // MARK: - Cleanup @Test("Cleanup on handler removal during header phase") From bb53459e03408b7ca890b33bea6b48e699d3481e Mon Sep 17 00:00:00 2001 From: Gus Cairo Date: Fri, 19 Jun 2026 17:57:32 +0100 Subject: [PATCH 20/20] Format --- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 6a4e1f9..6583fa7 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -252,7 +252,7 @@ extension NIOHTTPServer { ) > { channel.eventLoop.makeCompletedFuture { - return try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( mode: .server, connectionManagerConfiguration: .init( maxIdleTime: self.configuration.connectionTimeouts.idle.map { TimeAmount($0) },