Skip to content

Commit 5d14a76

Browse files
committed
add state to HTTP2StateMaschine and add testExecuteOnShuttingDownPool
1 parent 7d11497 commit 5d14a76

File tree

3 files changed

+180
-52
lines changed

3 files changed

+180
-52
lines changed

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

+121-50
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ extension HTTPConnectionPool {
2121
typealias RequestAction = HTTPConnectionPool.StateMachine.RequestAction
2222
typealias ConnectionAction = HTTPConnectionPool.StateMachine.ConnectionAction
2323

24+
private enum State: Equatable {
25+
case running
26+
case shuttingDown(unclean: Bool)
27+
case shutDown
28+
}
29+
2430
private var lastConnectFailure: Error?
2531
private var failedConsecutiveConnectionAttempts = 0
2632

@@ -31,6 +37,8 @@ extension HTTPConnectionPool {
3137

3238
private let idGenerator: Connection.ID.Generator
3339

40+
private var state: State = .running
41+
3442
init(
3543
idGenerator: Connection.ID.Generator
3644
) {
@@ -68,10 +76,24 @@ extension HTTPConnectionPool {
6876
}
6977

7078
mutating func executeRequest(_ request: Request) -> Action {
71-
if let eventLoop = request.requiredEventLoop {
72-
return self.executeRequest(request, onRequired: eventLoop)
73-
} else {
74-
return self.executeRequest(request, onPreferred: request.preferredEventLoop)
79+
switch self.state {
80+
case .running:
81+
if let eventLoop = request.requiredEventLoop {
82+
return self.executeRequest(request, onRequired: eventLoop)
83+
} else {
84+
return self.executeRequest(request, onPreferred: request.preferredEventLoop)
85+
}
86+
case .shutDown, .shuttingDown:
87+
// it is fairly unlikely that this condition is met, since the ConnectionPoolManager
88+
// also fails new requests immediately, if it is shutting down. However there might
89+
// be race conditions in which a request passes through a running connection pool
90+
// manager, but hits a connection pool that is already shutting down.
91+
//
92+
// (Order in one lock does not guarantee order in the next lock!)
93+
return .init(
94+
request: .failRequest(request, HTTPClientError.alreadyShutdown, cancelTimeout: false),
95+
connection: .none
96+
)
7597
}
7698
}
7799

@@ -149,36 +171,57 @@ extension HTTPConnectionPool {
149171
at index: Int,
150172
context: HTTP2Connections.AvailableConnectionContext
151173
) -> Action {
152-
// We prioritise requests with a required event loop over those without a requirement.
153-
// This can cause starvation for request without a required event loop.
154-
// We should come up with a better algorithm in the future.
155-
156-
var requestsToExecute = self.requests.popFirst(max: context.availableStreams, for: context.eventLoop)
157-
let remainingAvailableStreams = context.availableStreams - requestsToExecute.count
158-
// use the remaining available streams for requests without a required event loop
159-
requestsToExecute += self.requests.popFirst(max: remainingAvailableStreams, for: nil)
160-
let connection = self.connections.leaseStreams(at: index, count: requestsToExecute.count)
161-
162-
let requestAction = { () -> RequestAction in
163-
if requestsToExecute.isEmpty {
164-
return .none
165-
} else {
166-
return .executeRequestsAndCancelTimeouts(requestsToExecute, connection)
167-
}
168-
}()
174+
switch self.state {
175+
case .running:
176+
// We prioritise requests with a required event loop over those without a requirement.
177+
// This can cause starvation for request without a required event loop.
178+
// We should come up with a better algorithm in the future.
179+
180+
var requestsToExecute = self.requests.popFirst(max: context.availableStreams, for: context.eventLoop)
181+
let remainingAvailableStreams = context.availableStreams - requestsToExecute.count
182+
// use the remaining available streams for requests without a required event loop
183+
requestsToExecute += self.requests.popFirst(max: remainingAvailableStreams, for: nil)
184+
let connection = self.connections.leaseStreams(at: index, count: requestsToExecute.count)
185+
186+
let requestAction = { () -> RequestAction in
187+
if requestsToExecute.isEmpty {
188+
return .none
189+
} else {
190+
return .executeRequestsAndCancelTimeouts(requestsToExecute, connection)
191+
}
192+
}()
193+
194+
let connectionAction = { () -> ConnectionAction in
195+
if context.isIdle, requestsToExecute.isEmpty {
196+
return .scheduleTimeoutTimer(connection.id, on: context.eventLoop)
197+
} else {
198+
return .none
199+
}
200+
}()
169201

170-
let connectionAction = { () -> ConnectionAction in
171-
if context.isIdle, requestsToExecute.isEmpty {
172-
return .scheduleTimeoutTimer(connection.id, on: context.eventLoop)
173-
} else {
202+
return .init(
203+
request: requestAction,
204+
connection: connectionAction
205+
)
206+
case .shuttingDown(let unclean):
207+
guard context.isIdle else {
174208
return .none
175209
}
176-
}()
177210

178-
return .init(
179-
request: requestAction,
180-
connection: connectionAction
181-
)
211+
let connection = self.connections.closeConnection(at: index)
212+
if self.connections.isEmpty {
213+
return .init(
214+
request: .none,
215+
connection: .closeConnection(connection, isShutdown: .yes(unclean: unclean))
216+
)
217+
}
218+
return .init(
219+
request: .none,
220+
connection: .closeConnection(connection, isShutdown: .no)
221+
)
222+
case .shutDown:
223+
preconditionFailure("It the pool is already shutdown, all connections must have been torn down.")
224+
}
182225
}
183226

184227
mutating func newHTTP2MaxConcurrentStreamsReceived(_ connectionID: Connection.ID, newMaxStreams: Int) -> Action {
@@ -199,32 +242,53 @@ extension HTTPConnectionPool {
199242
}
200243

201244
private mutating func nextActionForFailedConnection(at index: Int, on eventLoop: EventLoop) -> Action {
202-
let hasPendingRequest = !self.requests.isEmpty(for: eventLoop) || !self.requests.isEmpty(for: nil)
203-
guard hasPendingRequest else {
204-
return .none
205-
}
245+
switch self.state {
246+
case .running:
247+
let hasPendingRequest = !self.requests.isEmpty(for: eventLoop) || !self.requests.isEmpty(for: nil)
248+
guard hasPendingRequest else {
249+
return .none
250+
}
206251

207-
let (newConnectionID, previousEventLoop) = self.connections.createNewConnectionByReplacingClosedConnection(at: index)
208-
precondition(previousEventLoop === eventLoop)
252+
let (newConnectionID, previousEventLoop) = self.connections.createNewConnectionByReplacingClosedConnection(at: index)
253+
precondition(previousEventLoop === eventLoop)
209254

210-
return .init(
211-
request: .none,
212-
connection: .createConnection(newConnectionID, on: eventLoop)
213-
)
255+
return .init(
256+
request: .none,
257+
connection: .createConnection(newConnectionID, on: eventLoop)
258+
)
259+
case .shuttingDown(let unclean):
260+
assert(self.requests.isEmpty)
261+
self.connections.removeConnection(at: index)
262+
if self.connections.isEmpty {
263+
return .init(
264+
request: .none,
265+
connection: .cleanupConnections(.init(), isShutdown: .yes(unclean: unclean))
266+
)
267+
}
268+
return .none
269+
270+
case .shutDown:
271+
preconditionFailure("If the pool is already shutdown, all connections must have been torn down.")
272+
}
214273
}
215274

216275
private mutating func nextActionForClosingConnection(on eventLoop: EventLoop) -> Action {
217-
let hasPendingRequest = !self.requests.isEmpty(for: eventLoop) || !self.requests.isEmpty(for: nil)
218-
guard hasPendingRequest else {
219-
return .none
220-
}
276+
switch self.state {
277+
case .running:
278+
let hasPendingRequest = !self.requests.isEmpty(for: eventLoop) || !self.requests.isEmpty(for: nil)
279+
guard hasPendingRequest else {
280+
return .none
281+
}
221282

222-
let newConnectionID = self.connections.createNewConnection(on: eventLoop)
283+
let newConnectionID = self.connections.createNewConnection(on: eventLoop)
223284

224-
return .init(
225-
request: .none,
226-
connection: .createConnection(newConnectionID, on: eventLoop)
227-
)
285+
return .init(
286+
request: .none,
287+
connection: .createConnection(newConnectionID, on: eventLoop)
288+
)
289+
case .shutDown, .shuttingDown:
290+
return .none
291+
}
228292
}
229293

230294
mutating func http2ConnectionStreamClosed(_ connectionID: Connection.ID) -> Action {
@@ -248,7 +312,7 @@ extension HTTPConnectionPool {
248312
guard let (index, context) = self.connections.failConnection(connectionID) else {
249313
preconditionFailure("Backing off a connection that is unknown to us?")
250314
}
251-
return nextActionForFailedConnection(at: index, on: context.eventLoop)
315+
return self.nextActionForFailedConnection(at: index, on: context.eventLoop)
252316
}
253317

254318
mutating func timeoutRequest(_ requestID: Request.ID) -> Action {
@@ -289,8 +353,13 @@ extension HTTPConnectionPool {
289353

290354
mutating func connectionIdleTimeout(_ connectionID: Connection.ID) -> Action {
291355
guard let connection = connections.closeConnectionIfIdle(connectionID) else {
356+
// because of a race this connection (connection close runs against trigger of timeout)
357+
// was already removed from the state machine.
292358
return .none
293359
}
360+
361+
precondition(self.state == .running, "If we are shutting down, we must not have any idle connections")
362+
294363
return .init(
295364
request: .none,
296365
connection: .closeConnection(connection, isShutdown: .no)
@@ -329,8 +398,10 @@ extension HTTPConnectionPool {
329398
let unclean = !(cleanupContext.cancel.isEmpty && waitingRequests.isEmpty)
330399
if self.connections.isEmpty {
331400
isShutdown = .yes(unclean: unclean)
401+
self.state = .shutDown
332402
} else {
333403
isShutdown = .no
404+
self.state = .shuttingDown(unclean: unclean)
334405
}
335406
return .init(
336407
request: requestAction,

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

+3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ extension HTTPConnectionPool_HTTP2StateMachineTests {
2626
static var allTests: [(String, (HTTPConnectionPool_HTTP2StateMachineTests) -> () throws -> Void)] {
2727
return [
2828
("testCreatingOfConnection", testCreatingOfConnection),
29+
("testConnectionFailureBackoff", testConnectionFailureBackoff),
30+
("testCancelRequestWorks", testCancelRequestWorks),
31+
("testExecuteOnShuttingDownPool", testExecuteOnShuttingDownPool),
2932
]
3033
}
3134
}

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

+56-2
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ class HTTPConnectionPool_HTTP2StateMachineTests: XCTestCase {
114114
isShutdown: .yes(unclean: false)
115115
))
116116
}
117-
117+
118118
func testConnectionFailureBackoff() {
119119
let elg = EmbeddedEventLoopGroup(loops: 4)
120120
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
@@ -170,7 +170,7 @@ class HTTPConnectionPool_HTTP2StateMachineTests: XCTestCase {
170170
// 4. retry connection, but no more queued requests.
171171
XCTAssertEqual(state.connectionCreationBackoffDone(newConnectionID), .none)
172172
}
173-
173+
174174
func testCancelRequestWorks() {
175175
let elg = EmbeddedEventLoopGroup(loops: 4)
176176
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
@@ -207,4 +207,58 @@ class HTTPConnectionPool_HTTP2StateMachineTests: XCTestCase {
207207
XCTAssertEqual(connectedAction.request, .none, "Request must not be executed")
208208
XCTAssertEqual(connectedAction.connection, .scheduleTimeoutTimer(connectionID, on: connectionEL))
209209
}
210+
211+
func testExecuteOnShuttingDownPool() {
212+
let elg = EmbeddedEventLoopGroup(loops: 4)
213+
defer { XCTAssertNoThrow(try elg.syncShutdownGracefully()) }
214+
215+
var state = HTTPConnectionPool.HTTP2StateMaschine(
216+
idGenerator: .init()
217+
)
218+
219+
let mockRequest = MockHTTPRequest(eventLoop: elg.next())
220+
let request = HTTPConnectionPool.Request(mockRequest)
221+
222+
let executeAction = state.executeRequest(request)
223+
XCTAssertEqual(.scheduleRequestTimeout(for: request, on: mockRequest.eventLoop), executeAction.request)
224+
225+
// 1. connection attempt
226+
guard case .createConnection(let connectionID, on: let connectionEL) = executeAction.connection else {
227+
return XCTFail("Unexpected connection action: \(executeAction.connection)")
228+
}
229+
XCTAssert(connectionEL === mockRequest.eventLoop) // XCTAssertIdentical not available on Linux
230+
231+
// 2. connection succeeds
232+
let connection: HTTPConnectionPool.Connection = .__testOnly_connection(id: connectionID, eventLoop: connectionEL)
233+
let connectedAction = state.newHTTP2ConnectionEstablished(connection, maxConcurrentStreams: 100)
234+
guard case .executeRequestsAndCancelTimeouts([request], connection) = connectedAction.request else {
235+
return XCTFail("Unexpected request action: \(connectedAction.request)")
236+
}
237+
XCTAssert(request.__testOnly_wrapped_request() === mockRequest) // XCTAssertIdentical not available on Linux
238+
XCTAssertEqual(connectedAction.connection, .none)
239+
240+
// 3. shutdown
241+
let shutdownAction = state.shutdown()
242+
XCTAssertEqual(.none, shutdownAction.request)
243+
guard case .cleanupConnections(let cleanupContext, isShutdown: .no) = shutdownAction.connection else {
244+
return XCTFail("Unexpected connection action: \(shutdownAction.connection)")
245+
}
246+
247+
XCTAssertEqual(cleanupContext.cancel.count, 1)
248+
XCTAssertEqual(cleanupContext.cancel.first?.id, connectionID)
249+
XCTAssertEqual(cleanupContext.close, [])
250+
XCTAssertEqual(cleanupContext.connectBackoff, [])
251+
252+
// 4. execute another request
253+
let finalMockRequest = MockHTTPRequest(eventLoop: elg.next())
254+
let finalRequest = HTTPConnectionPool.Request(finalMockRequest)
255+
let failAction = state.executeRequest(finalRequest)
256+
XCTAssertEqual(failAction.connection, .none)
257+
XCTAssertEqual(failAction.request, .failRequest(finalRequest, HTTPClientError.alreadyShutdown, cancelTimeout: false))
258+
259+
// 5. close open connection
260+
let closeAction = state.connectionClosed(connectionID)
261+
XCTAssertEqual(closeAction.connection, .cleanupConnections(.init(), isShutdown: .yes(unclean: true)))
262+
XCTAssertEqual(closeAction.request, .none)
263+
}
210264
}

0 commit comments

Comments
 (0)