Skip to content

Commit 07b6889

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 07b6889

File tree

6 files changed

+89
-1
lines changed

6 files changed

+89
-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

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

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

777825
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)