Skip to content

Commit 9a8553e

Browse files
authored
Correctly close the connection if sendEnd fails (#599)
Motivation If we receive an early HTTP response, the last action on a HTTP/1.1 connection is to send the .end message. While we had an error handling path in the code, it wasn't tested, and when executed it would end up leaking the connection by failing to close it _or_ return it to the pool. This patch fixes the issue by appropriately terminating the connection and adding a test. Modifications Add a test Terminate the connection if sendEnd fails Result Fewer connection leaks
1 parent ac34f6d commit 9a8553e

File tree

3 files changed

+87
-0
lines changed

3 files changed

+87
-0
lines changed

Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
279279

280280
oldRequest.succeedRequest(buffer)
281281
case .failure(let error):
282+
context.close(promise: nil)
282283
oldRequest.fail(error)
283284
}
284285
}

Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests+XCTest.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ extension HTTP1ClientChannelHandlerTests {
3232
("testIdleReadTimeoutIsCanceledIfRequestIsCanceled", testIdleReadTimeoutIsCanceledIfRequestIsCanceled),
3333
("testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand", testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand),
3434
("testWriteHTTPHeadFails", testWriteHTTPHeadFails),
35+
("testHandlerClosesChannelIfLastActionIsSendEndAndItFails", testHandlerClosesChannelIfLastActionIsSendEndAndItFails),
3536
]
3637
}
3738
}

Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,75 @@ class HTTP1ClientChannelHandlerTests: XCTestCase {
457457
XCTAssertEqual(embedded.isActive, false)
458458
}
459459
}
460+
461+
func testHandlerClosesChannelIfLastActionIsSendEndAndItFails() {
462+
let embedded = EmbeddedChannel()
463+
let testWriter = TestBackpressureWriter(eventLoop: embedded.eventLoop, parts: 5)
464+
var maybeTestUtils: HTTP1TestTools?
465+
XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection())
466+
guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") }
467+
468+
var maybeRequest: HTTPClient.Request?
469+
XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/", method: .POST, body: .stream(length: 10) { writer in
470+
testWriter.start(writer: writer)
471+
}))
472+
guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") }
473+
474+
let delegate = ResponseAccumulator(request: request)
475+
var maybeRequestBag: RequestBag<ResponseAccumulator>?
476+
XCTAssertNoThrow(maybeRequestBag = try RequestBag(
477+
request: request,
478+
eventLoopPreference: .delegate(on: embedded.eventLoop),
479+
task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger),
480+
redirectHandler: nil,
481+
connectionDeadline: .now() + .seconds(30),
482+
requestOptions: .forTests(idleReadTimeout: .milliseconds(200)),
483+
delegate: delegate
484+
))
485+
guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") }
486+
487+
XCTAssertNoThrow(try embedded.pipeline.addHandler(FailEndHandler(), position: .first).wait())
488+
489+
// Execute the request and we'll receive the head.
490+
testWriter.writabilityChanged(true)
491+
testUtils.connection.executeRequest(requestBag)
492+
XCTAssertNoThrow(try embedded.receiveHeadAndVerify {
493+
XCTAssertEqual($0.method, .POST)
494+
XCTAssertEqual($0.uri, "/")
495+
XCTAssertEqual($0.headers.first(name: "host"), "localhost")
496+
XCTAssertEqual($0.headers.first(name: "content-length"), "10")
497+
})
498+
// We're going to immediately send the response head and end.
499+
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
500+
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead)))
501+
embedded.read()
502+
503+
// Send the end and confirm the connection is still live.
504+
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil)))
505+
XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 0)
506+
XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0)
507+
508+
// Ok, now we can process some reads. We expect 5 reads, but we do _not_ expect an .end, because
509+
// the `FailEndHandler` is going to fail it.
510+
embedded.embeddedEventLoop.run()
511+
XCTAssertEqual(testWriter.written, 5)
512+
for _ in 0..<5 {
513+
XCTAssertNoThrow(try embedded.receiveBodyAndVerify {
514+
XCTAssertEqual($0.readableBytes, 2)
515+
})
516+
}
517+
518+
embedded.embeddedEventLoop.run()
519+
XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self))
520+
521+
// We should have seen the connection close, and the request is complete.
522+
XCTAssertEqual(testUtils.connectionDelegate.hitConnectionClosed, 1)
523+
XCTAssertEqual(testUtils.connectionDelegate.hitConnectionReleased, 0)
524+
525+
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) { error in
526+
XCTAssertTrue(error is FailEndHandler.Error)
527+
}
528+
}
460529
}
461530

462531
class TestBackpressureWriter {
@@ -636,3 +705,19 @@ class ReadEventHitHandler: ChannelOutboundHandler {
636705
context.read()
637706
}
638707
}
708+
709+
final class FailEndHandler: ChannelOutboundHandler {
710+
typealias OutboundIn = HTTPClientRequestPart
711+
typealias OutboundOut = HTTPClientRequestPart
712+
713+
struct Error: Swift.Error {}
714+
715+
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
716+
if case .end = self.unwrapOutboundIn(data) {
717+
// We fail this.
718+
promise?.fail(Self.Error())
719+
} else {
720+
context.write(data, promise: promise)
721+
}
722+
}
723+
}

0 commit comments

Comments
 (0)