Skip to content

Commit 459efe1

Browse files
committed
Add defensive connection closure.
Motivation: Currently when either we or the server send Connection: close, we correctly do not return that connection to the pool. However, we rely on the server actually performing the connection closure: we never call close() ourselves. This is unnecessarily optimistic: a server may absolutely fail to close this connection. To protect our own file descriptors, we should make sure that any connection we do not return the pool is closed. Modifications: If we think a connection is closing when we release it, we now call close() on it defensively. Result: We no longer leak connections when the server fails to close them. Fixes #324.
1 parent a72c5ad commit 459efe1

File tree

6 files changed

+88
-1
lines changed

6 files changed

+88
-1
lines changed

Diff for: Sources/AsyncHTTPClient/Connection.swift

+4
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ extension Connection {
7272
func close() -> EventLoopFuture<Void> {
7373
return self.channel.close()
7474
}
75+
76+
func close(promise: EventLoopPromise<Void>?) {
77+
return self.channel.close(promise: promise)
78+
}
7579
}
7680

7781
/// Methods of Connection which are used in ConnectionsState extracted as protocol

Diff for: Sources/AsyncHTTPClient/ConnectionPool.swift

+9
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,15 @@ class HTTP1ConnectionProvider {
352352
self.state.release(connection: connection, closing: closing)
353353
}
354354

355+
// We close defensively here: we may have failed to actually close on other codepaths,
356+
// or we may be expecting the server to close. In either case, we want our FD back, so
357+
// we close now to cover our backs. We don't care about the result: if the channel is
358+
// _already_ closed, that's fine by us.
359+
if closing {
360+
logger.debug("closing connection")
361+
connection.close(promise: nil)
362+
}
363+
355364
switch action {
356365
case .none:
357366
break

Diff for: Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ class HTTPClientInternalTests: XCTestCase {
812812

813813
XCTAssert(connection !== connection2)
814814
try! connection2.channel.eventLoop.submit {
815-
connection2.release(closing: true, logger: HTTPClient.loggingDisabled)
815+
connection2.release(closing: false, logger: HTTPClient.loggingDisabled)
816816
}.wait()
817817
XCTAssertTrue(connection2.channel.isActive)
818818
}

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

+47
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,53 @@ internal final class HttpBinForSSLUncleanShutdownHandler: ChannelInboundHandler
772772
}
773773
}
774774

775+
internal final class CloseWithoutClosingServerHandler: ChannelInboundHandler {
776+
typealias InboundIn = HTTPServerRequestPart
777+
typealias OutboundOut = HTTPServerResponsePart
778+
779+
private var callback: (() -> Void)?
780+
private var onClosePromise: EventLoopPromise<Void>?
781+
782+
init(_ callback: @escaping () -> Void) {
783+
self.callback = callback
784+
}
785+
786+
func handlerAdded(context: ChannelHandlerContext) {
787+
self.onClosePromise = context.eventLoop.makePromise()
788+
self.onClosePromise!.futureResult.whenSuccess(self.callback!)
789+
self.callback = nil
790+
}
791+
792+
func handlerRemoved(context: ChannelHandlerContext) {
793+
assert(self.onClosePromise == nil)
794+
}
795+
796+
func channelInactive(context: ChannelHandlerContext) {
797+
if let onClosePromise = self.onClosePromise {
798+
self.onClosePromise = nil
799+
onClosePromise.succeed(())
800+
}
801+
}
802+
803+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
804+
guard case .end = self.unwrapInboundIn(data) else {
805+
return
806+
}
807+
808+
// We're gonna send a response back here, with Connection: close, but we will
809+
// not close the connection. This reproduces #324.
810+
let headers = HTTPHeaders([
811+
("Host", "CloseWithoutClosingServerHandler"),
812+
("Content-Length", "0"),
813+
("Connection", "close"),
814+
])
815+
let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .ok, headers: headers)
816+
817+
context.write(self.wrapOutboundOut(.head(head)), promise: nil)
818+
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
819+
}
820+
}
821+
775822
struct EventLoopFutureTimeoutError: Error {}
776823

777824
extension EventLoopFuture {

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ extension HTTPClientTests {
126126
("testNoBytesSentOverBodyLimit", testNoBytesSentOverBodyLimit),
127127
("testDoubleError", testDoubleError),
128128
("testSSLHandshakeErrorPropagation", testSSLHandshakeErrorPropagation),
129+
("testWeCloseConnectionsWhenConnectionCloseSetByServer", testWeCloseConnectionsWhenConnectionCloseSetByServer),
129130
]
130131
}
131132
}

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTests.swift

+26
Original file line numberDiff line numberDiff line change
@@ -2712,4 +2712,30 @@ class HTTPClientTests: XCTestCase {
27122712
#endif
27132713
}
27142714
}
2715+
2716+
func testWeCloseConnectionsWhenConnectionCloseSetByServer() throws {
2717+
let group = DispatchGroup()
2718+
group.enter()
2719+
2720+
let server = try ServerBootstrap(group: self.serverGroup)
2721+
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
2722+
.childChannelInitializer { channel in
2723+
channel.pipeline.configureHTTPServerPipeline().flatMap {
2724+
channel.pipeline.addHandler(CloseWithoutClosingServerHandler(group.leave))
2725+
}
2726+
}
2727+
.bind(host: "localhost", port: 0)
2728+
.wait()
2729+
2730+
defer {
2731+
server.close(promise: nil)
2732+
}
2733+
2734+
// Simple request, should go great.
2735+
XCTAssertNoThrow(try self.defaultClient.get(url: "http://localhost:\(server.localAddress!.port!)/").wait())
2736+
2737+
// Shouldn't need more than 100ms of waiting to see the close.
2738+
let result = group.wait(timeout: .now().advanced(by: .milliseconds(100)))
2739+
XCTAssertEqual(result, .success, "we never closed the connection!")
2740+
}
27152741
}

0 commit comments

Comments
 (0)