Skip to content

Commit fc510a3

Browse files
dnadobaLukasa
andauthored
Fix thread leak in FileDownloadDelegate (#614)
* Fix thread leak in `FileDownloadDelegate` * `SwiftFormat` * Add a shared file IO thread pool per HTTPClient * User bigger thread pool and initlize lazily during first file write * thread pool is actually not used in tests * Update documentation * fix review comments * make `fileIOThreadPool` internal * Add test to verify that we actually share the same thread pool across all delegates for a given HTTPClient Co-authored-by: Cory Benfield <[email protected]>
1 parent 0469acb commit fc510a3

7 files changed

+166
-18
lines changed

Sources/AsyncHTTPClient/FileDownloadDelegate.swift

+49-8
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public final class FileDownloadDelegate: HTTPClientResponseDelegate {
3030
public typealias Response = Progress
3131

3232
private let filePath: String
33-
private let io: NonBlockingFileIO
33+
private(set) var fileIOThreadPool: NIOThreadPool?
3434
private let reportHead: ((HTTPResponseHead) -> Void)?
3535
private let reportProgress: ((Progress) -> Void)?
3636

@@ -47,14 +47,46 @@ public final class FileDownloadDelegate: HTTPClientResponseDelegate {
4747
/// the total byte count and download byte count passed to it as arguments. The callbacks
4848
/// will be invoked in the same threading context that the delegate itself is invoked,
4949
/// as controlled by `EventLoopPreference`.
50-
public init(
50+
public convenience init(
5151
path: String,
52-
pool: NIOThreadPool = NIOThreadPool(numberOfThreads: 1),
52+
pool: NIOThreadPool,
5353
reportHead: ((HTTPResponseHead) -> Void)? = nil,
5454
reportProgress: ((Progress) -> Void)? = nil
5555
) throws {
56-
pool.start()
57-
self.io = NonBlockingFileIO(threadPool: pool)
56+
try self.init(path: path, pool: .some(pool), reportHead: reportHead, reportProgress: reportProgress)
57+
}
58+
59+
/// Initializes a new file download delegate and uses the shared thread pool of the ``HTTPClient`` for file I/O.
60+
///
61+
/// - parameters:
62+
/// - path: Path to a file you'd like to write the download to.
63+
/// - reportHead: A closure called when the response head is available.
64+
/// - reportProgress: A closure called when a body chunk has been downloaded, with
65+
/// the total byte count and download byte count passed to it as arguments. The callbacks
66+
/// will be invoked in the same threading context that the delegate itself is invoked,
67+
/// as controlled by `EventLoopPreference`.
68+
public convenience init(
69+
path: String,
70+
reportHead: ((HTTPResponseHead) -> Void)? = nil,
71+
reportProgress: ((Progress) -> Void)? = nil
72+
) throws {
73+
try self.init(path: path, pool: nil, reportHead: reportHead, reportProgress: reportProgress)
74+
}
75+
76+
private init(
77+
path: String,
78+
pool: NIOThreadPool?,
79+
reportHead: ((HTTPResponseHead) -> Void)? = nil,
80+
reportProgress: ((Progress) -> Void)? = nil
81+
) throws {
82+
if let pool = pool {
83+
self.fileIOThreadPool = pool
84+
} else {
85+
// we should use the shared thread pool from the HTTPClient which
86+
// we will get from the `HTTPClient.Task`
87+
self.fileIOThreadPool = nil
88+
}
89+
5890
self.filePath = path
5991

6092
self.reportHead = reportHead
@@ -79,24 +111,33 @@ public final class FileDownloadDelegate: HTTPClientResponseDelegate {
79111
task: HTTPClient.Task<Response>,
80112
_ buffer: ByteBuffer
81113
) -> EventLoopFuture<Void> {
114+
let threadPool: NIOThreadPool = {
115+
guard let pool = self.fileIOThreadPool else {
116+
let pool = task.fileIOThreadPool
117+
self.fileIOThreadPool = pool
118+
return pool
119+
}
120+
return pool
121+
}()
122+
let io = NonBlockingFileIO(threadPool: threadPool)
82123
self.progress.receivedBytes += buffer.readableBytes
83124
self.reportProgress?(self.progress)
84125

85126
let writeFuture: EventLoopFuture<Void>
86127
if let fileHandleFuture = self.fileHandleFuture {
87128
writeFuture = fileHandleFuture.flatMap {
88-
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
129+
io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
89130
}
90131
} else {
91-
let fileHandleFuture = self.io.openFile(
132+
let fileHandleFuture = io.openFile(
92133
path: self.filePath,
93134
mode: .write,
94135
flags: .allowFileCreation(),
95136
eventLoop: task.eventLoop
96137
)
97138
self.fileHandleFuture = fileHandleFuture
98139
writeFuture = fileHandleFuture.flatMap {
99-
self.io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
140+
io.write(fileHandle: $0, buffer: buffer, eventLoop: task.eventLoop)
100141
}
101142
}
102143

Sources/AsyncHTTPClient/HTTPClient.swift

+36-6
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ public class HTTPClient {
7272
let eventLoopGroupProvider: EventLoopGroupProvider
7373
let configuration: Configuration
7474
let poolManager: HTTPConnectionPool.Manager
75+
76+
/// Shared thread pool used for file IO. It is lazily created on first access of ``Task/fileIOThreadPool``.
77+
private var fileIOThreadPool: NIOThreadPool?
78+
private let fileIOThreadPoolLock = Lock()
79+
7580
private var state: State
7681
private let stateLock = Lock()
7782

@@ -213,6 +218,16 @@ public class HTTPClient {
213218
}
214219
}
215220

221+
private func shutdownFileIOThreadPool(queue: DispatchQueue, _ callback: @escaping (Error?) -> Void) {
222+
self.fileIOThreadPoolLock.withLockVoid {
223+
guard let fileIOThreadPool = fileIOThreadPool else {
224+
callback(nil)
225+
return
226+
}
227+
fileIOThreadPool.shutdownGracefully(queue: queue, callback)
228+
}
229+
}
230+
216231
private func shutdown(requiresCleanClose: Bool, queue: DispatchQueue, _ callback: @escaping (Error?) -> Void) {
217232
do {
218233
try self.stateLock.withLock {
@@ -241,15 +256,28 @@ public class HTTPClient {
241256
let error: Error? = (requiresClean && unclean) ? HTTPClientError.uncleanShutdown : nil
242257
return (callback, error)
243258
}
244-
245-
self.shutdownEventLoop(queue: queue) { error in
246-
let reportedError = error ?? uncleanError
247-
callback(reportedError)
259+
self.shutdownFileIOThreadPool(queue: queue) { ioThreadPoolError in
260+
self.shutdownEventLoop(queue: queue) { error in
261+
let reportedError = error ?? ioThreadPoolError ?? uncleanError
262+
callback(reportedError)
263+
}
248264
}
249265
}
250266
}
251267
}
252268

269+
private func makeOrGetFileIOThreadPool() -> NIOThreadPool {
270+
self.fileIOThreadPoolLock.withLock {
271+
guard let fileIOThreadPool = fileIOThreadPool else {
272+
let fileIOThreadPool = NIOThreadPool(numberOfThreads: ProcessInfo.processInfo.processorCount)
273+
fileIOThreadPool.start()
274+
self.fileIOThreadPool = fileIOThreadPool
275+
return fileIOThreadPool
276+
}
277+
return fileIOThreadPool
278+
}
279+
}
280+
253281
/// Execute `GET` request using specified URL.
254282
///
255283
/// - parameters:
@@ -562,6 +590,7 @@ public class HTTPClient {
562590
case .testOnly_exact(_, delegateOn: let delegateEL):
563591
taskEL = delegateEL
564592
}
593+
565594
logger.trace("selected EventLoop for task given the preference",
566595
metadata: ["ahc-eventloop": "\(taskEL)",
567596
"ahc-el-preference": "\(eventLoopPreference)"])
@@ -574,7 +603,8 @@ public class HTTPClient {
574603
logger.debug("client is shutting down, failing request")
575604
return Task<Delegate.Response>.failedTask(eventLoop: taskEL,
576605
error: HTTPClientError.alreadyShutdown,
577-
logger: logger)
606+
logger: logger,
607+
makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool)
578608
}
579609
}
580610

@@ -597,7 +627,7 @@ public class HTTPClient {
597627
}
598628
}()
599629

600-
let task = Task<Delegate.Response>(eventLoop: taskEL, logger: logger)
630+
let task = Task<Delegate.Response>(eventLoop: taskEL, logger: logger, makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool)
601631
do {
602632
let requestBag = try RequestBag(
603633
request: request,

Sources/AsyncHTTPClient/HTTPHandler.swift

+17-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Logging
1717
import NIOConcurrencyHelpers
1818
import NIOCore
1919
import NIOHTTP1
20+
import NIOPosix
2021
import NIOSSL
2122

2223
extension HTTPClient {
@@ -502,7 +503,7 @@ public protocol HTTPClientResponseDelegate: AnyObject {
502503
}
503504

504505
extension HTTPClientResponseDelegate {
505-
/// Default implementation of ``HTTPClientResponseDelegate/didSendRequestHead(task:_:)-6khai``.
506+
/// Default implementation of ``HTTPClientResponseDelegate/didSendRequest(task:)-9od5p``.
506507
///
507508
/// By default, this does nothing.
508509
public func didSendRequestHead(task: HTTPClient.Task<Response>, _ head: HTTPRequestHead) {}
@@ -622,15 +623,27 @@ extension HTTPClient {
622623
private var _isCancelled: Bool = false
623624
private var _taskDelegate: HTTPClientTaskDelegate?
624625
private let lock = Lock()
626+
private let makeOrGetFileIOThreadPool: () -> NIOThreadPool
625627

626-
init(eventLoop: EventLoop, logger: Logger) {
628+
/// The shared thread pool of a ``HTTPClient`` used for file IO. It is lazily created on first access.
629+
internal var fileIOThreadPool: NIOThreadPool {
630+
self.makeOrGetFileIOThreadPool()
631+
}
632+
633+
init(eventLoop: EventLoop, logger: Logger, makeOrGetFileIOThreadPool: @escaping () -> NIOThreadPool) {
627634
self.eventLoop = eventLoop
628635
self.promise = eventLoop.makePromise()
629636
self.logger = logger
637+
self.makeOrGetFileIOThreadPool = makeOrGetFileIOThreadPool
630638
}
631639

632-
static func failedTask(eventLoop: EventLoop, error: Error, logger: Logger) -> Task<Response> {
633-
let task = self.init(eventLoop: eventLoop, logger: logger)
640+
static func failedTask(
641+
eventLoop: EventLoop,
642+
error: Error,
643+
logger: Logger,
644+
makeOrGetFileIOThreadPool: @escaping () -> NIOThreadPool
645+
) -> Task<Response> {
646+
let task = self.init(eventLoop: eventLoop, logger: logger, makeOrGetFileIOThreadPool: makeOrGetFileIOThreadPool)
634647
task.promise.fail(error)
635648
return task
636649
}

Tests/AsyncHTTPClientTests/HTTPClientInternalTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ extension HTTPClientInternalTests {
3636
("testConnectErrorCalloutOnCorrectEL", testConnectErrorCalloutOnCorrectEL),
3737
("testInternalRequestURI", testInternalRequestURI),
3838
("testHasSuffix", testHasSuffix),
39+
("testSharedThreadPoolIsIdenticalForAllDelegates", testSharedThreadPoolIsIdenticalForAllDelegates),
3940
]
4041
}
4142
}

Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift

+35
Original file line numberDiff line numberDiff line change
@@ -554,4 +554,39 @@ class HTTPClientInternalTests: XCTestCase {
554554
XCTAssertFalse(elements.hasSuffix([0, 0, 0]))
555555
}
556556
}
557+
558+
/// test to verify that we actually share the same thread pool across all ``FileDownloadDelegate``s for a given ``HTTPClient``
559+
func testSharedThreadPoolIsIdenticalForAllDelegates() throws {
560+
let httpBin = HTTPBin()
561+
let httpClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup))
562+
defer {
563+
XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true))
564+
XCTAssertNoThrow(try httpBin.shutdown())
565+
}
566+
var request = try Request(url: "http://localhost:\(httpBin.port)/events/10/content-length")
567+
request.headers.add(name: "Accept", value: "text/event-stream")
568+
569+
let filePaths = (0..<10).map { _ in
570+
TemporaryFileHelpers.makeTemporaryFilePath()
571+
}
572+
defer {
573+
for filePath in filePaths {
574+
TemporaryFileHelpers.removeTemporaryFile(at: filePath)
575+
}
576+
}
577+
let delegates = try filePaths.map {
578+
try FileDownloadDelegate(path: $0)
579+
}
580+
581+
let resultFutures = delegates.map { delegate in
582+
httpClient.execute(
583+
request: request,
584+
delegate: delegate
585+
).futureResult
586+
}
587+
_ = try EventLoopFuture.whenAllSucceed(resultFutures, on: self.clientGroup.next()).wait()
588+
let threadPools = delegates.map { $0.fileIOThreadPool }
589+
let firstThreadPool = threadPools.first ?? nil
590+
XCTAssert(threadPools.dropFirst().allSatisfy { $0 === firstThreadPool })
591+
}
557592
}

Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

+17
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,23 @@ enum TemporaryFileHelpers {
283283
return try body(path)
284284
}
285285

286+
internal static func makeTemporaryFilePath(
287+
directory: String = temporaryDirectory
288+
) -> String {
289+
let (fd, path) = self.openTemporaryFile()
290+
close(fd)
291+
try! FileManager.default.removeItem(atPath: path)
292+
return path
293+
}
294+
295+
internal static func removeTemporaryFile(
296+
at path: String
297+
) {
298+
if FileManager.default.fileExists(atPath: path) {
299+
try? FileManager.default.removeItem(atPath: path)
300+
}
301+
}
302+
286303
internal static func fileSize(path: String) throws -> Int? {
287304
return try FileManager.default.attributesOfItem(atPath: path)[.size] as? Int
288305
}

Tests/AsyncHTTPClientTests/RequestBagTests.swift

+11
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,17 @@ final class RequestBagTests: XCTestCase {
771771
}
772772
}
773773

774+
extension HTTPClient.Task {
775+
convenience init(
776+
eventLoop: EventLoop,
777+
logger: Logger
778+
) {
779+
self.init(eventLoop: eventLoop, logger: logger) {
780+
preconditionFailure("thread pool not needed in tests")
781+
}
782+
}
783+
}
784+
774785
class UploadCountingDelegate: HTTPClientResponseDelegate {
775786
typealias Response = Void
776787

0 commit comments

Comments
 (0)