From a1af5ad68e042e45f8a1bd944a8b2941495c55ba Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 11 Jun 2026 14:58:24 +0100 Subject: [PATCH 1/4] Close active HTTP/2 connections upon server task cancellation --- .../NIOHTTPServer+SecureUpgrade.swift | 12 +++ .../NIOHTTPServer+ServiceLifecycleTests.swift | 89 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index bba1c16..d726090 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -150,6 +150,18 @@ extension NIOHTTPServer { metadata: ["error": "\(error)"] ) } + + do { + try await connectionChannel.close() + } catch ChannelError.alreadyClosed { + // We swallow the error here because the connection channel may already have closed at this point, e.g. + // if the client sent a TCP FIN or a TLS CLOSE_NOTIFY that the event loop processed before we got here. + } catch { + self.logger.error( + "Error thrown while closing the HTTP/2 connection channel", + metadata: ["error": "\(error)"] + ) + } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 4145d02..cb2c32c 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -19,6 +19,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOHTTPTypes import NIOPosix +import NIOSSL import ServiceLifecycle import ServiceLifecycleTestKit import Testing @@ -126,6 +127,94 @@ struct NIOHTTPServiceLifecycleTests { } } + @Test( + "Server closes active connection upon forceful shutdown", + arguments: [HTTPVersion.http1_1, HTTPVersion.http2] + ) + @available(anyAppleOS 26.0, *) + func testServerClosesActiveConnectionOnForcefulShutdown(httpVersion: HTTPVersion) async throws { + let (server, serverChain) = try NIOHTTPServerTests.makeSecureUpgradeServer(logger: self.serverLogger) + + // This promise will be fulfilled when the server receives the first part of the request body. Once this + // happens, we cancel the server task and test whether the client's socket channel has closed. + let elg = MultiThreadedEventLoopGroup.singletonMultiThreadedEventLoopGroup + let firstChunkReadPromise = elg.any().makePromise(of: Void.self) + + let serverService = ClosureService { + await #expect(throws: CancellationError.self) { + try await server.serve { request, requestContext, requestReader, responseSender in + // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + let error = try await #require(throws: EitherError.self) { + try await bodyReader.read { _ in } + + firstChunkReadPromise.succeed() + + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive. + try await bodyReader.read { _ in } + } + #expect(throws: CancellationError.self) { try error.unwrap() } + } + } + } + } + + try await withThrowingTaskGroup { group in + let serviceGroup = ServiceGroup(services: [serverService], logger: self.serviceGroupLogger) + group.addTask { try await serviceGroup.run() } + + let serverAddress = try await server.listeningAddresses.first! + + let tlsConfig = try TLSConfiguration.makeTestClientConfiguration( + testTrustRoots: serverChain.chain, + applicationProtocol: httpVersion.alpnIdentifier + ) + + let (clientConnectionChannel, alpnResultFuture) = + try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup).connect( + to: try .init(ipAddress: serverAddress.host, port: serverAddress.port) + ) { socketChannel in + socketChannel.configureTestClientSSLPipeline(tlsConfig: tlsConfig).flatMap { + socketChannel.configureTestSecureUpgradeClientPipeline().map { connectionChannel in + (socketChannel, connectionChannel) + } + } + } + + let alpnResult = try await alpnResultFuture.get() + let clientRequestChannel = try await NegotiatedClientConnection(negotiationResult: alpnResult) + .unwrapChannel(expectedHTTPVersion: httpVersion) + + try await clientRequestChannel.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + + // Write the first body part. + try await outbound.write(Self.reqBody) + + // Wait until the server has received the first body part. + try await firstChunkReadPromise.futureResult.get() + + // Cancel the server task. + group.cancelAll() + // Wait for the server to shut down. + try await group.waitForAll() + + // Wait for the client channel to be fully closed. + try await clientRequestChannel.channel.closeFuture.get() + + // We shouldn't be able to complete our request; the server should have shut down. + await #expect(throws: ChannelError.ioOnClosedChannel) { + try await outbound.write(Self.reqBody) + } + } + + try await clientConnectionChannel.closeFuture.get() + } + } + @Test( "Active connection forcefully shutdown when server task cancelled", arguments: [HTTPVersion.http1_1, HTTPVersion.http2] From 34722f812be1d21269286aa049a3f974210568ab Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 11 Jun 2026 17:19:24 +0100 Subject: [PATCH 2/4] Add comment --- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index d726090..be52f2c 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -151,6 +151,8 @@ extension NIOHTTPServer { ) } + // The `multiplexer.inbound` iteration exits when our task is cancelled, or when the HTTP/2 stream + // multiplexer finishes or throws. In any case, we are done with this connection here, so tear it down. do { try await connectionChannel.close() } catch ChannelError.alreadyClosed { From df15bdacec04ff631dad3349050a285a4a9f316d Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 18 Jun 2026 09:08:37 +0100 Subject: [PATCH 3/4] Empty commit to re-trigger CI checks From 868b4accf87d852fe84006205fbe6b06e8484339 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 18 Jun 2026 15:45:14 +0100 Subject: [PATCH 4/4] Fix test --- .../NIOHTTPServer+ServiceLifecycleTests.swift | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index b455f17..5812317 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -136,21 +136,18 @@ struct NIOHTTPServiceLifecycleTests { let serverService = ClosureService { await #expect(throws: CancellationError.self) { try await server.serve { request, requestContext, requestReader, responseSender in + var requestReader = requestReader // Read the first chunk, signal `firstChunkReadPromise`, then try to read the second chunk. - _ = try await requestReader.consumeAndConclude { bodyReader in - var bodyReader = bodyReader - - let error = try await #require(throws: EitherError.self) { - try await bodyReader.read { _ in } + let error = try await #require(throws: EitherError.self) { + try await requestReader.read { _, _ in } - firstChunkReadPromise.succeed() + firstChunkReadPromise.succeed() - // The following call will block: the client will never send a request end part. This is - // intentional because we want to keep the connection alive. - try await bodyReader.read { _ in } - } - #expect(throws: CancellationError.self) { try error.unwrap() } + // The following call will block: the client will never send a request end part. This is + // intentional because we want to keep the connection alive. + try await requestReader.read { _, _ in } } + #expect(throws: CancellationError.self) { try error.unwrap() } } } }