From 65d9b945ea2d6e1bf635828aeb261ce5c4748613 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 1 Oct 2026 15:34:58 +0100 Subject: [PATCH 1/3] Add a hook to customise client connection pipeline in tests --- Package.swift | 2 +- .../HTTP3DatagramTests.swift | 16 ++++-- Tests/NIOHTTPServerTests/TestError.swift | 6 +++ .../Utilities/NIOClient/NIOClient+HTTP1.swift | 13 +++-- .../Utilities/NIOClient/NIOClient+HTTP3.swift | 10 ++-- .../NIOClient/NIOClient+SecureUpgrade.swift | 23 ++++++-- .../Utilities/TestClientConnection.swift | 52 +++++++++++++++++-- 7 files changed, 101 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index 3b7b9de..39c017c 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,7 @@ var traits: Set = [ ), ] -let defaultTraits: Set = ["Configuration"] +let defaultTraits: Set = ["Configuration", "HTTP3", "UnstableHTTPDatagrams"] // Workaround to ensure that all traits are included in documentation. Swift Package Index adds SPI_GENERATE_DOCS // (https://github.com/SwiftPackageIndex/SwiftPackageIndex-Server/issues/2336) when building documentation, so only diff --git a/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift b/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift index a7ac895..bb63d4d 100644 --- a/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift @@ -220,13 +220,15 @@ struct HTTP3DatagramTests { @Test("Read and write datagrams") @available(anyAppleOS 26.2, *) func readAndWriteDatagrams() async throws { - let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + var (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( for: .http3, clientLogger: Self.clientLogger, serverLogger: Self.serverLogger ) let streamOpenedPromise = server.eventLoopGroup.any().makePromise(of: Void.self) + let datagramsSupportedPromise = server.eventLoopGroup.any().makePromise(of: Void.self) + clientConfiguration.addDatagramSettingsHandler(promise: datagramsSupportedPromise) try await TestHelpers.withHTTP3ClientServerConnectionAndRequestChannel( clientConfiguration: clientConfiguration, @@ -255,8 +257,9 @@ struct HTTP3DatagramTests { // Start the request. try await streamOutbound.write(.testHead(method: .post, for: .http3)) - // Wait for the stream to be opened. - try await streamOpenedPromise.futureResult.get() + // Wait for the stream to be opened, and until the server has sent their SETTINGS frame that + // confirms they also support receiving datagrams. + _ = try await streamOpenedPromise.futureResult.and(datagramsSupportedPromise.futureResult).get() // Now write a datagram. try await connectionOutbound.write(HTTP3Datagram(streamID: streamID, payload: .testData)) @@ -296,6 +299,8 @@ struct HTTP3DatagramTests { ) let streamOpenedPromise = server.eventLoopGroup.any().makePromise(of: Void.self) + let datagramsSupportedPromise = server.eventLoopGroup.any().makePromise(of: Void.self) + clientConfiguration.addDatagramSettingsHandler(promise: datagramsSupportedPromise) try await TestHelpers.withHTTP3ClientServerConnectionAndRequestChannel( clientConfiguration: clientConfiguration, @@ -336,8 +341,9 @@ struct HTTP3DatagramTests { // Start the request. try await streamOutbound.write(.testHead(method: .get, for: .http3)) - // Wait for the stream to be opened. - try await streamOpenedPromise.futureResult.get() + // Wait for the stream to be opened, and until the server has sent their SETTINGS frame that + // confirms they also support receiving datagrams. + _ = try await streamOpenedPromise.futureResult.and(datagramsSupportedPromise.futureResult).get() // Write a datagram. try await connectionOutbound.write(HTTP3Datagram(streamID: streamID, payload: .testData)) diff --git a/Tests/NIOHTTPServerTests/TestError.swift b/Tests/NIOHTTPServerTests/TestError.swift index f2f7317..0637937 100644 --- a/Tests/NIOHTTPServerTests/TestError.swift +++ b/Tests/NIOHTTPServerTests/TestError.swift @@ -19,4 +19,10 @@ enum TestError: Error { /// Thrown when the client configuration is invalid for the configured HTTP version. case invalidClientConfiguration + + /// Datagrams are not supported. + case datagramsNotSupported + + /// The connection closed before the peer's SETTINGS frame was received. + case connectionClosedBeforeSettingsReceived } diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift index 756242e..ea58ef5 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP1.swift @@ -21,10 +21,11 @@ import NIOPosix @available(anyAppleOS 26.0, *) extension Channel { - /// Adds HTTP/1.1 client handlers to the pipeline. + /// Adds HTTP/1.1 client handlers to the pipeline, then calls `additionalConnectionChannelInitializer` if provided. func configureTestHTTP1ClientPipeline( responseLeftOverBytesStrategy: RemoveAfterUpgradeStrategy = .dropBytes, - informationalResponseStrategy: NIOInformationalResponseStrategy = .forward + informationalResponseStrategy: NIOInformationalResponseStrategy = .forward, + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) -> EventLoopFuture> { self.eventLoop.makeCompletedFuture { let handlers: [ChannelHandler] = [ @@ -39,6 +40,7 @@ extension Channel { HTTP1ToHTTPClientCodec(), ] try self.pipeline.syncOperations.addHandlers(handlers) + try additionalConnectionChannelInitializer?(self) return try NIOAsyncChannel( wrappingChannelSynchronously: self, @@ -55,14 +57,17 @@ extension ClientBootstrap { /// `NIOAsyncChannel` for writing `HTTPRequestPart`s to the server and observing `HTTPResponsePart`s from its /// inbound stream. func connectToTestHTTP1Server( - at serverAddress: NIOHTTPServer.SocketAddress + at serverAddress: NIOHTTPServer.SocketAddress, + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) async throws -> TestClientConnection { .init( connectionProtocol: .http1( connectionChannel: try await self.connect( to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) ) { channel in - channel.configureTestHTTP1ClientPipeline() + channel.configureTestHTTP1ClientPipeline( + additionalConnectionChannelInitializer: additionalConnectionChannelInitializer + ) } ) ) diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift index f0748cc..6ccff70 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+HTTP3.swift @@ -69,7 +69,8 @@ extension Channel { settings: HTTP3Settings, configuration: HTTP3ClientConfiguration, quicConfiguration: QUICConfiguration, - asyncVerifier: NIOQUIC.AsyncVerifier + asyncVerifier: NIOQUIC.AsyncVerifier, + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) throws -> TestHTTP3SingleConnectionCreator { let quicHandler = QUICHandler( channel: self, @@ -96,6 +97,7 @@ extension Channel { inboundPushStreamInitializer: { _ in fatalError() } ) try connectionChannel.pipeline.syncOperations.addHandler(h3Handler) + try additionalConnectionChannelInitializer?(connectionChannel) return connectionChannel } }, @@ -120,7 +122,8 @@ extension DatagramBootstrap { trustRootsPath: String, quicConfiguration: QUICConfiguration, http3ClientConfiguration: HTTP3ClientConfiguration = .defaults, - http3ConnectionSettings: HTTP3Settings = .init() + http3ConnectionSettings: HTTP3Settings = .init(), + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) async throws -> (any Channel, NIOLoopBound) { try await self.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .bind(host: "127.0.0.1", port: 0) { channel in @@ -134,7 +137,8 @@ extension DatagramBootstrap { trustRootsPath: trustRootsPath, certificateVerification: .noHostnameVerification, eventLoop: channel.eventLoop - ) + ), + additionalConnectionChannelInitializer: additionalConnectionChannelInitializer ) let loopBoundConnectionCreator = NIOLoopBound(connectionCreator, eventLoop: channel.eventLoop) diff --git a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift index 8c30e1b..4693ecf 100644 --- a/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/NIOClient/NIOClient+SecureUpgrade.swift @@ -32,8 +32,12 @@ extension Channel { } } - /// Adds an ALPN handler (configured with both HTTP/1.1 and HTTP/2 channel initializers) to the pipeline. - func configureTestSecureUpgradeClientPipeline() -> EventLoopFuture< + /// Adds an ALPN handler (configured with both HTTP/1.1 and HTTP/2 channel initializers) to the pipeline. Once a + /// protocol is negotiated, `additionalConnectionChannelInitializer` is optionally called after the protocol's + /// handlers are added. + func configureTestSecureUpgradeClientPipeline( + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil + ) -> EventLoopFuture< EventLoopFuture< NIONegotiatedHTTPVersion< NIOAsyncChannel, @@ -43,10 +47,16 @@ extension Channel { > { self.configureHTTP2AsyncSecureUpgrade( http1ConnectionInitializer: { channel in - channel.configureTestHTTP1ClientPipeline() + channel.configureTestHTTP1ClientPipeline( + additionalConnectionChannelInitializer: additionalConnectionChannelInitializer + ) }, http2ConnectionInitializer: { channel in channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } + .flatMapThrowing { multiplexer in + try additionalConnectionChannelInitializer?(channel) + return multiplexer + } } ) } @@ -57,13 +67,16 @@ extension ClientBootstrap { /// Connects the client to the specified address using the provided TLS configuration. func connectToTestSecureUpgradeHTTPServer( at serverAddress: NIOHTTPServer.SocketAddress, - tlsConfig: TLSConfiguration + tlsConfig: TLSConfiguration, + additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil ) async throws -> TestClientConnection { let (connectionChannel, alpnResultFuture) = try await self.connect( to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) ) { channel in channel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { - channel.configureTestSecureUpgradeClientPipeline().map { alpnResultFuture in + channel.configureTestSecureUpgradeClientPipeline( + additionalConnectionChannelInitializer: additionalConnectionChannelInitializer + ).map { alpnResultFuture in (channel, alpnResultFuture) } } diff --git a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift index 2b916e2..f289d62 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift @@ -26,6 +26,7 @@ import Testing #if HTTP3 import HTTP3 +import NIOExtras @_spi(HTTP3AsyncInterface) import NIOHTTP3 import NIOQUIC import NIOQUICHelpers @@ -189,6 +190,10 @@ extension TestClientConnection { var trustRootsPEMPath: String? var chain: ChainPrivateKeyPair? = nil + /// An additional initializer called on the client's connection channel after the protocol-specific handlers + /// have been added. + var additionalConnectionChannelInitializer: (@Sendable (any Channel) throws -> Void)? = nil + #if HTTP3 /// The client's QUIC configuration. Only applies when ``httpVersion`` is `.http3`. var quicConfiguration: QUICConfiguration? = nil @@ -210,7 +215,10 @@ extension TestClientConnection { switch (configuration.httpVersion, configuration.trustRootsPEMPath) { case (.plaintextHTTP1_1, .none): connection = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestHTTP1Server(at: serverAddress) + .connectToTestHTTP1Server( + at: serverAddress, + additionalConnectionChannelInitializer: configuration.additionalConnectionChannelInitializer + ) case (.http1_1, .some(let trustRootsPEMPath)), (.http2, .some(let trustRootsPEMPath)): let tlsConfiguration = @@ -228,7 +236,11 @@ extension TestClientConnection { } connection = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .connectToTestSecureUpgradeHTTPServer(at: serverAddress, tlsConfig: tlsConfiguration) + .connectToTestSecureUpgradeHTTPServer( + at: serverAddress, + tlsConfig: tlsConfiguration, + additionalConnectionChannelInitializer: configuration.additionalConnectionChannelInitializer + ) #if HTTP3 case (.http3, .some(let trustRootsPEMPath)): @@ -238,7 +250,8 @@ extension TestClientConnection { trustRootsPath: trustRootsPEMPath, quicConfiguration: configuration.quicConfiguration ?? .makeClientQUICConfig(caPath: trustRootsPEMPath), - http3ConnectionSettings: configuration.http3ConnectionSettings + http3ConnectionSettings: configuration.http3ConnectionSettings, + additionalConnectionChannelInitializer: configuration.additionalConnectionChannelInitializer ) let multiplexer = HTTP3ClientConnectionMultiplexer< @@ -366,3 +379,36 @@ extension NIOHTTP2Handler.AsyncStreamMultiplexer { } } } + +#if HTTP3 && UnstableHTTPDatagrams +@available(anyAppleOS 26.0, *) +extension TestClientConnection.Configuration { + /// Sets up ``additionalConnectionChannelInitializer`` to add a handler that observes the SETTINGS frame received + /// from the server, and succeeds `promise` once the server has confirmed it supports receiving datagrams. + mutating func addDatagramSettingsHandler(promise datagramsSupportedPromise: EventLoopPromise) { + let existingInitializer = self.additionalConnectionChannelInitializer + + self.additionalConnectionChannelInitializer = { connectionChannel in + try existingInitializer?(connectionChannel) + + let settingsObserver = DebugInboundEventsHandler { event, _ in + switch event { + case .userInboundEventTriggered(let event as ReceivedSettings): + if event.datagramsSupported { + datagramsSupportedPromise.succeed() + } else { + datagramsSupportedPromise.fail(TestError.datagramsNotSupported) + } + + case .inactive: + datagramsSupportedPromise.fail(TestError.connectionClosedBeforeSettingsReceived) + + default: + () + } + } + try connectionChannel.pipeline.syncOperations.addHandler(settingsObserver) + } + } +} +#endif // HTTP3 && UnstableHTTPDatagrams From 5b0b9ad87ae0acd3e1361dfb99c12f9d1d686e5f Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 1 Oct 2026 15:40:56 +0100 Subject: [PATCH 2/3] Remove accidental trait change --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 39c017c..3b7b9de 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,7 @@ var traits: Set = [ ), ] -let defaultTraits: Set = ["Configuration", "HTTP3", "UnstableHTTPDatagrams"] +let defaultTraits: Set = ["Configuration"] // Workaround to ensure that all traits are included in documentation. Swift Package Index adds SPI_GENERATE_DOCS // (https://github.com/SwiftPackageIndex/SwiftPackageIndex-Server/issues/2336) when building documentation, so only From d6033cdda1d00ab0ab705947589bc1042154c24d Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 2 Oct 2026 12:14:04 +0100 Subject: [PATCH 3/3] Raise availability --- Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift index 534e349..75acde1 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift @@ -381,7 +381,7 @@ extension NIOHTTP2Handler.AsyncStreamMultiplexer { } #if HTTP3 && UnstableHTTPDatagrams -@available(anyAppleOS 26.0, *) +@available(anyAppleOS 27.0, *) extension TestClientConnection.Configuration { /// Sets up ``additionalConnectionChannelInitializer`` to add a handler that observes the SETTINGS frame received /// from the server, and succeeds `promise` once the server has confirmed it supports receiving datagrams.