Skip to content

Commit d5bd8d6

Browse files
authored
Always clear read idle timeout at the end of a request (#455)
1 parent a0b0985 commit d5bd8d6

6 files changed

+122
-4
lines changed

Sources/AsyncHTTPClient/ConnectionPool/HTTP1.1/HTTP1ClientChannelHandler.swift

+14-3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
5252
private var idleReadTimeoutStateMachine: IdleReadStateMachine?
5353
private var idleReadTimeoutTimer: Scheduled<Void>?
5454

55+
/// Cancelling a task in NIO does *not* guarantee that the task will not execute under certain race conditions.
56+
/// We therefore give each timer an ID and increase the ID every time we reset or cancel it.
57+
/// We check in the task if the timer ID has changed in the meantime and do not execute any action if has changed.
58+
private var currentIdleReadTimeoutTimerID: Int = 0
59+
5560
private let backgroundLogger: Logger
5661
private var logger: Logger
5762

@@ -253,6 +258,7 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
253258

254259
let oldRequest = self.request!
255260
self.request = nil
261+
self.runTimeoutAction(.clearIdleReadTimeoutTimer, context: context)
256262

257263
switch finalAction {
258264
case .close:
@@ -271,6 +277,7 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
271277
// see comment in the `succeedRequest` case.
272278
let oldRequest = self.request!
273279
self.request = nil
280+
self.runTimeoutAction(.clearIdleReadTimeoutTimer, context: context)
274281

275282
switch finalAction {
276283
case .close:
@@ -292,7 +299,9 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
292299
case .startIdleReadTimeoutTimer(let timeAmount):
293300
assert(self.idleReadTimeoutTimer == nil, "Expected there is no timeout timer so far.")
294301

302+
let timerID = self.currentIdleReadTimeoutTimerID
295303
self.idleReadTimeoutTimer = self.eventLoop.scheduleTask(in: timeAmount) {
304+
guard self.currentIdleReadTimeoutTimerID == timerID else { return }
296305
let action = self.state.idleReadTimeoutTriggered()
297306
self.run(action, context: context)
298307
}
@@ -302,17 +311,19 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
302311
oldTimer.cancel()
303312
}
304313

314+
self.currentIdleReadTimeoutTimerID &+= 1
315+
let timerID = self.currentIdleReadTimeoutTimerID
305316
self.idleReadTimeoutTimer = self.eventLoop.scheduleTask(in: timeAmount) {
317+
guard self.currentIdleReadTimeoutTimerID == timerID else { return }
306318
let action = self.state.idleReadTimeoutTriggered()
307319
self.run(action, context: context)
308320
}
309-
310321
case .clearIdleReadTimeoutTimer:
311322
if let oldTimer = self.idleReadTimeoutTimer {
312323
self.idleReadTimeoutTimer = nil
324+
self.currentIdleReadTimeoutTimerID &+= 1
313325
oldTimer.cancel()
314326
}
315-
316327
case .none:
317328
break
318329
}
@@ -465,7 +476,7 @@ struct IdleReadStateMachine {
465476
return .resetIdleReadTimeoutTimer(self.timeAmount)
466477
case .end:
467478
self.state = .responseEndReceived
468-
return .clearIdleReadTimeoutTimer
479+
return .none
469480
}
470481

471482
case .responseEndReceived:

Sources/AsyncHTTPClient/ConnectionPool/HTTP2/HTTP2ClientRequestHandler.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,13 @@ final class HTTP2ClientRequestHandler: ChannelDuplexHandler {
196196
case .failRequest(let error, let finalAction):
197197
self.request!.fail(error)
198198
self.request = nil
199+
self.runTimeoutAction(.clearIdleReadTimeoutTimer, context: context)
199200
self.runFinalAction(finalAction, context: context)
200201

201202
case .succeedRequest(let finalAction, let finalParts):
202203
self.request!.succeedRequest(finalParts)
203204
self.request = nil
205+
self.runTimeoutAction(.clearIdleReadTimeoutTimer, context: context)
204206
self.runFinalAction(finalAction, context: context)
205207
}
206208
}
@@ -224,6 +226,7 @@ final class HTTP2ClientRequestHandler: ChannelDuplexHandler {
224226
assert(self.idleReadTimeoutTimer == nil, "Expected there is no timeout timer so far.")
225227

226228
self.idleReadTimeoutTimer = self.eventLoop.scheduleTask(in: timeAmount) {
229+
guard self.idleReadTimeoutTimer != nil else { return }
227230
let action = self.state.idleReadTimeoutTriggered()
228231
self.run(action, context: context)
229232
}
@@ -234,10 +237,10 @@ final class HTTP2ClientRequestHandler: ChannelDuplexHandler {
234237
}
235238

236239
self.idleReadTimeoutTimer = self.eventLoop.scheduleTask(in: timeAmount) {
240+
guard self.idleReadTimeoutTimer != nil else { return }
237241
let action = self.state.idleReadTimeoutTriggered()
238242
self.run(action, context: context)
239243
}
240-
241244
case .clearIdleReadTimeoutTimer:
242245
if let oldTimer = self.idleReadTimeoutTimer {
243246
self.idleReadTimeoutTimer = nil

Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ extension HTTP1ClientChannelHandlerTests {
2929
("testWriteBackpressure", testWriteBackpressure),
3030
("testClientHandlerCancelsRequestIfWeWantToShutdown", testClientHandlerCancelsRequestIfWeWantToShutdown),
3131
("testIdleReadTimeout", testIdleReadTimeout),
32+
("testIdleReadTimeoutIsCanceledIfRequestIsCanceled", testIdleReadTimeoutIsCanceledIfRequestIsCanceled),
3233
("testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand", testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand),
3334
]
3435
}

Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift

+50
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,56 @@ class HTTP1ClientChannelHandlerTests: XCTestCase {
287287
}
288288
}
289289

290+
func testIdleReadTimeoutIsCanceledIfRequestIsCanceled() {
291+
let embedded = EmbeddedChannel()
292+
var maybeTestUtils: HTTP1TestTools?
293+
XCTAssertNoThrow(maybeTestUtils = try embedded.setupHTTP1Connection())
294+
guard let testUtils = maybeTestUtils else { return XCTFail("Expected connection setup works") }
295+
296+
var maybeRequest: HTTPClient.Request?
297+
XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/"))
298+
guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") }
299+
300+
let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop)
301+
var maybeRequestBag: RequestBag<ResponseBackpressureDelegate>?
302+
XCTAssertNoThrow(maybeRequestBag = try RequestBag(
303+
request: request,
304+
eventLoopPreference: .delegate(on: embedded.eventLoop),
305+
task: .init(eventLoop: embedded.eventLoop, logger: testUtils.logger),
306+
redirectHandler: nil,
307+
connectionDeadline: .now() + .seconds(30),
308+
requestOptions: .forTests(idleReadTimeout: .milliseconds(200)),
309+
delegate: delegate
310+
))
311+
guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") }
312+
313+
testUtils.connection.executeRequest(requestBag)
314+
315+
XCTAssertNoThrow(try embedded.receiveHeadAndVerify {
316+
XCTAssertEqual($0.method, .GET)
317+
XCTAssertEqual($0.uri, "/")
318+
XCTAssertEqual($0.headers.first(name: "host"), "localhost")
319+
})
320+
XCTAssertNoThrow(try embedded.receiveEnd())
321+
322+
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "12")]))
323+
324+
XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 0)
325+
embedded.read()
326+
XCTAssertEqual(testUtils.readEventHandler.readHitCounter, 1)
327+
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead)))
328+
329+
// canceling the request
330+
requestBag.cancel()
331+
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
332+
XCTAssertEqual($0 as? HTTPClientError, .cancelled)
333+
}
334+
335+
// the idle read timeout should be cleared because we canceled the request
336+
// therefore advancing the time should not trigger a crash
337+
embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250))
338+
}
339+
290340
func testFailHTTPRequestWithContentLengthBecauseOfChannelInactiveWaitingForDemand() {
291341
let embedded = EmbeddedChannel()
292342
var maybeTestUtils: HTTP1TestTools?

Tests/AsyncHTTPClientTests/HTTP2ClientRequestHandlerTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ extension HTTP2ClientRequestHandlerTests {
2828
("testResponseBackpressure", testResponseBackpressure),
2929
("testWriteBackpressure", testWriteBackpressure),
3030
("testIdleReadTimeout", testIdleReadTimeout),
31+
("testIdleReadTimeoutIsCanceledIfRequestIsCanceled", testIdleReadTimeoutIsCanceledIfRequestIsCanceled),
3132
]
3233
}
3334
}

Tests/AsyncHTTPClientTests/HTTP2ClientRequestHandlerTests.swift

+52
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,56 @@ class HTTP2ClientRequestHandlerTests: XCTestCase {
233233
XCTAssertEqual($0 as? HTTPClientError, .readTimeout)
234234
}
235235
}
236+
237+
func testIdleReadTimeoutIsCanceledIfRequestIsCanceled() {
238+
let embedded = EmbeddedChannel()
239+
let readEventHandler = ReadEventHitHandler()
240+
let requestHandler = HTTP2ClientRequestHandler(eventLoop: embedded.eventLoop)
241+
XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandlers([readEventHandler, requestHandler]))
242+
XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait())
243+
let logger = Logger(label: "test")
244+
245+
var maybeRequest: HTTPClient.Request?
246+
XCTAssertNoThrow(maybeRequest = try HTTPClient.Request(url: "http://localhost/"))
247+
guard let request = maybeRequest else { return XCTFail("Expected to be able to create a request") }
248+
249+
let delegate = ResponseBackpressureDelegate(eventLoop: embedded.eventLoop)
250+
var maybeRequestBag: RequestBag<ResponseBackpressureDelegate>?
251+
XCTAssertNoThrow(maybeRequestBag = try RequestBag(
252+
request: request,
253+
eventLoopPreference: .delegate(on: embedded.eventLoop),
254+
task: .init(eventLoop: embedded.eventLoop, logger: logger),
255+
redirectHandler: nil,
256+
connectionDeadline: .now() + .seconds(30),
257+
requestOptions: .forTests(idleReadTimeout: .milliseconds(200)),
258+
delegate: delegate
259+
))
260+
guard let requestBag = maybeRequestBag else { return XCTFail("Expected to be able to create a request bag") }
261+
262+
embedded.write(requestBag, promise: nil)
263+
264+
XCTAssertNoThrow(try embedded.receiveHeadAndVerify {
265+
XCTAssertEqual($0.method, .GET)
266+
XCTAssertEqual($0.uri, "/")
267+
XCTAssertEqual($0.headers.first(name: "host"), "localhost")
268+
})
269+
XCTAssertNoThrow(try embedded.receiveEnd())
270+
271+
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok, headers: HTTPHeaders([("content-length", "12")]))
272+
273+
XCTAssertEqual(readEventHandler.readHitCounter, 0)
274+
embedded.read()
275+
XCTAssertEqual(readEventHandler.readHitCounter, 1)
276+
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead)))
277+
278+
// canceling the request
279+
requestBag.cancel()
280+
XCTAssertThrowsError(try requestBag.task.futureResult.wait()) {
281+
XCTAssertEqual($0 as? HTTPClientError, .cancelled)
282+
}
283+
284+
// the idle read timeout should be cleared because we canceled the request
285+
// therefore advancing the time should not trigger a crash
286+
embedded.embeddedEventLoop.advanceTime(by: .milliseconds(250))
287+
}
236288
}

0 commit comments

Comments
 (0)