Skip to content

Commit 7bb58e5

Browse files
authored
Fix a race between shutdown and backoff timer (#419)
1 parent 4d726ba commit 7bb58e5

File tree

3 files changed

+58
-9
lines changed

3 files changed

+58
-9
lines changed

Diff for: Sources/AsyncHTTPClient/ConnectionPool/State Machine/HTTPConnectionPool+HTTP1StateMachine.swift

+18-9
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,25 @@ extension HTTPConnectionPool {
168168
}
169169

170170
mutating func connectionCreationBackoffDone(_ connectionID: Connection.ID) -> Action {
171-
assert(self.connections.stats.backingOff >= 1, "At least this connection is currently in backoff")
172-
// The naming of `failConnection` is a little confusing here. All it does is moving the
173-
// connection state from `.backingOff` to `.closed` here. It also returns the
174-
// connection's index.
175-
guard let (index, context) = self.connections.failConnection(connectionID) else {
176-
preconditionFailure("Backing off a connection that is unknown to us?")
171+
switch self.state {
172+
case .running:
173+
// The naming of `failConnection` is a little confusing here. All it does is moving the
174+
// connection state from `.backingOff` to `.closed` here. It also returns the
175+
// connection's index.
176+
guard let (index, context) = self.connections.failConnection(connectionID) else {
177+
preconditionFailure("Backing off a connection that is unknown to us?")
178+
}
179+
// In `nextActionForFailedConnection` a decision will be made whether the failed
180+
// connection should be replaced or removed.
181+
return self.nextActionForFailedConnection(at: index, context: context)
182+
183+
case .shuttingDown, .shutDown:
184+
// There might be a race between shutdown and a backoff timer firing. On thread A
185+
// we might call shutdown which removes the backoff timer. On thread B the backoff
186+
// timer might fire at the same time and be blocked by the state lock. In this case
187+
// we would look for the backoff timer that was removed just before by the shutdown.
188+
return .none
177189
}
178-
// In `nextActionForFailedConnection` a decision will be made whether the failed
179-
// connection should be replaced or removed.
180-
return self.nextActionForFailedConnection(at: index, context: context)
181190
}
182191

183192
mutating func connectionIdleTimeout(_ connectionID: Connection.ID) -> Action {

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP1StateTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ extension HTTPConnectionPool_HTTP1StateMachineTests {
3737
("testParkedConnectionTimesOut", testParkedConnectionTimesOut),
3838
("testConnectionPoolFullOfParkedConnectionsIsShutdownImmediately", testConnectionPoolFullOfParkedConnectionsIsShutdownImmediately),
3939
("testParkedConnectionTimesOutButIsAlsoClosedByRemote", testParkedConnectionTimesOutButIsAlsoClosedByRemote),
40+
("testConnectionBackoffVsShutdownRace", testConnectionBackoffVsShutdownRace),
4041
]
4142
}
4243
}

Diff for: Tests/AsyncHTTPClientTests/HTTPConnectionPool+HTTP1StateTests.swift

+39
Original file line numberDiff line numberDiff line change
@@ -587,4 +587,43 @@ class HTTPConnectionPool_HTTP1StateMachineTests: XCTestCase {
587587
// triggered by timer
588588
XCTAssertEqual(state.connectionIdleTimeout(connection.id), .none)
589589
}
590+
591+
func testConnectionBackoffVsShutdownRace() {
592+
let elg = EmbeddedEventLoopGroup(loops: 2)
593+
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
594+
595+
var state = HTTPConnectionPool.StateMachine(
596+
eventLoopGroup: elg,
597+
idGenerator: .init(),
598+
maximumConcurrentHTTP1Connections: 6
599+
)
600+
601+
let mockRequest = MockHTTPRequest(eventLoop: elg.next(), requiresEventLoopForChannel: false)
602+
let request = HTTPConnectionPool.Request(mockRequest)
603+
604+
let executeAction = state.executeRequest(request)
605+
guard case .createConnection(let connectionID, on: let connEL) = executeAction.connection else {
606+
return XCTFail("Expected to create a connection")
607+
}
608+
609+
XCTAssertEqual(executeAction.request, .scheduleRequestTimeout(for: request, on: mockRequest.eventLoop))
610+
611+
let failAction = state.failedToCreateNewConnection(HTTPClientError.cancelled, connectionID: connectionID)
612+
guard case .scheduleBackoffTimer(connectionID, backoff: _, on: let timerEL) = failAction.connection else {
613+
return XCTFail("Expected to create a backoff timer")
614+
}
615+
XCTAssert(timerEL === connEL)
616+
XCTAssertEqual(failAction.request, .none)
617+
618+
let shutdownAction = state.shutdown()
619+
guard case .cleanupConnections(let context, isShutdown: .yes(unclean: true)) = shutdownAction.connection else {
620+
return XCTFail("Expected to cleanup")
621+
}
622+
XCTAssertEqual(context.close.count, 0)
623+
XCTAssertEqual(context.cancel.count, 0)
624+
XCTAssertEqual(context.connectBackoff, [connectionID])
625+
XCTAssertEqual(shutdownAction.request, .failRequestsAndCancelTimeouts([request], HTTPClientError.cancelled))
626+
627+
XCTAssertEqual(state.connectionCreationBackoffDone(connectionID), .none)
628+
}
590629
}

0 commit comments

Comments
 (0)