Skip to content

Commit 8dcda68

Browse files
authored
Ensure that we fire channelActive and channelInactive in order (#373)
Motivation: channelActive must always precede channelInactive. Unfortunately, this isn't guaranteed right now: if we happen to get channelInactive as a result of our writing the preamble we'll fire them in the wrong order. Many systems can tolerate this, but we should avoid doing this anyway. Modifications: - Added a state enum to keep track of the state we're in - Use that state enum to ensure we deliver active/inactive in the right order - Additionally police other unexpected active/inactive behaviours Result: We'll appropriately police the state.
1 parent 5fcdf62 commit 8dcda68

File tree

6 files changed

+370
-6
lines changed

6 files changed

+370
-6
lines changed

Sources/NIOHTTP2/HTTP2ChannelHandler.swift

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
9292
/// which can cause an infinite recursion.
9393
private var isUnbufferingAndFlushingAutomaticFrames = false
9494

95+
/// In some cases channelInactive can be fired while channelActive is still running.
96+
private var activationState = ActivationState.idle
97+
98+
/// Whether we should tolerate "impossible" state transitions in debug mode. Only true in tests specifically trying to
99+
/// trigger them.
100+
private let tolerateImpossibleStateTransitionsInDebugMode: Bool
101+
95102
/// The mode for this parser to operate in: client or server.
96103
public enum ParserMode {
97104
/// Client mode
@@ -152,6 +159,36 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
152159
self.initialSettings = initialSettings
153160
self.outboundBuffer = CompoundOutboundBuffer(mode: mode, initialMaxOutboundStreams: 100, maxBufferedControlFrames: maximumBufferedControlFrames)
154161
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames)
162+
self.tolerateImpossibleStateTransitionsInDebugMode = false
163+
}
164+
165+
/// Constructs a ``NIOHTTP2Handler``.
166+
///
167+
/// - parameters:
168+
/// - mode: The mode for this handler, client or server.
169+
/// - initialSettings: The settings that will be advertised to the peer in the preamble. Defaults to ``nioDefaultSettings``.
170+
/// - headerBlockValidation: Whether to validate sent and received HTTP/2 header blocks. Defaults to ``ValidationState/enabled``.
171+
/// - contentLengthValidation: Whether to validate the content length of sent and received streams. Defaults to ``ValidationState/enabled``.
172+
/// - maximumSequentialEmptyDataFrames: Controls the number of empty data frames this handler will tolerate receiving in a row before DoS protection
173+
/// is triggered and the connection is terminated. Defaults to 1.
174+
/// - maximumBufferedControlFrames: Controls the maximum buffer size of buffered outbound control frames. If we are unable to send control frames as
175+
/// fast as we produce them we risk building up an unbounded buffer and exhausting our memory. To protect against this DoS vector, we put an
176+
/// upper limit on the depth of this queue. Defaults to 10,000.
177+
/// - tolerateImpossibleStateTransitionsInDebugMode: Whether impossible state transitions should be tolerated
178+
/// in debug mode.
179+
internal init(mode: ParserMode,
180+
initialSettings: HTTP2Settings = nioDefaultSettings,
181+
headerBlockValidation: ValidationState = .enabled,
182+
contentLengthValidation: ValidationState = .enabled,
183+
maximumSequentialEmptyDataFrames: Int = 1,
184+
maximumBufferedControlFrames: Int = 10000,
185+
tolerateImpossibleStateTransitionsInDebugMode: Bool = false) {
186+
self.stateMachine = HTTP2ConnectionStateMachine(role: .init(mode), headerBlockValidation: .init(headerBlockValidation), contentLengthValidation: .init(contentLengthValidation))
187+
self.mode = mode
188+
self.initialSettings = initialSettings
189+
self.outboundBuffer = CompoundOutboundBuffer(mode: mode, initialMaxOutboundStreams: 100, maxBufferedControlFrames: maximumBufferedControlFrames)
190+
self.denialOfServiceValidator = DOSHeuristics(maximumSequentialEmptyDataFrames: maximumSequentialEmptyDataFrames)
191+
self.tolerateImpossibleStateTransitionsInDebugMode = tolerateImpossibleStateTransitionsInDebugMode
155192
}
156193

157194
public func handlerAdded(context: ChannelHandlerContext) {
@@ -160,6 +197,11 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
160197
self.writeBuffer = context.channel.allocator.buffer(capacity: 128)
161198

162199
if context.channel.isActive {
200+
// We jump immediately to activated here, as channelActive has probably already passed.
201+
if self.activationState != .idle {
202+
self.impossibleActivationStateTransition(state: self.activationState, activating: true, context: context)
203+
}
204+
self.activationState = .activated
163205
self.writeAndFlushPreamble(context: context)
164206
}
165207
}
@@ -170,13 +212,80 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {
170212
}
171213

172214
public func channelActive(context: ChannelHandlerContext) {
215+
// Check our activation state.
216+
switch self.activationState {
217+
case .idle:
218+
// This is our first channelActive. We're now activating.
219+
self.activationState = .activating
220+
case .activated:
221+
// This is a weird one, but it implies we "beat" the channelActive
222+
// call down the pipeline when we added in handlerAdded. That's ok!
223+
// We can safely ignore this.
224+
return
225+
case .activating, .inactiveWhileActivating, .inactive:
226+
// All of these states are channel pipeline invariant violations, but conceptually possible.
227+
//
228+
// - .activating implies we got another channelActive while we were handling one. That would be
229+
// a violation of pipeline invariants.
230+
// - .inactiveWhileActivating implies a sequence of channelActive, channelInactive, channelActive
231+
// synchronously. This is not just unlikely, but also misuse of the handler or violation of
232+
// channel pipeline invariants.
233+
// - .inactive implies we received channelInactive and then got another active. This is almost certainly
234+
// misuse of the handler.
235+
//
236+
// We'll throw an error and then close. In debug builds, we crash.
237+
self.impossibleActivationStateTransition(
238+
state: self.activationState, activating: true, context: context
239+
)
240+
return
241+
}
242+
173243
self.writeAndFlushPreamble(context: context)
174-
context.fireChannelActive()
244+
245+
// Ok, we progressed. Now we check our state again.
246+
switch self.activationState {
247+
case .activating:
248+
// This is the easy case: nothing exciting happened. We can activate and notify the pipeline.
249+
self.activationState = .activated
250+
context.fireChannelActive()
251+
case .inactiveWhileActivating:
252+
// This is awkward: we got a channelInactive during the above operation. We need to fire channelActive
253+
// and then re-issue the channelInactive call.
254+
self.activationState = .activated
255+
context.fireChannelActive()
256+
self.channelInactive(context: context)
257+
case .idle, .activated, .inactive:
258+
// These three states should be impossible.
259+
//
260+
// - .idle somehow implies we didn't execute the code above.
261+
// - .activated implies that the above code didn't prevent us re-entrantly getting to this point.
262+
// - .inactive implies that somehow we hit channelInactive but didn't enter .inactiveWhileActivating.
263+
self.impossibleActivationStateTransition(
264+
state: self.activationState, activating: true, context: context
265+
)
266+
}
175267
}
176268

177269
public func channelInactive(context: ChannelHandlerContext) {
178-
self.channelClosed = true
179-
context.fireChannelInactive()
270+
switch self.activationState {
271+
case .activated:
272+
// This is the easy one. We were active, now we aren't.
273+
self.activationState = .inactive
274+
self.channelClosed = true
275+
context.fireChannelInactive()
276+
case .activating:
277+
// Huh, we got channelInactive during activation. We need to maintain
278+
// ordering, so we'll temporarily delay this.
279+
self.activationState = .inactiveWhileActivating
280+
case .idle, .inactiveWhileActivating, .inactive:
281+
// This is weird.
282+
//
283+
// .idle implies that somehow we got channelInactive before channelActive, which is probably an error.
284+
// .inactiveWhileActivating and .inactive make this a second channelInactive call, which is also probably
285+
// an error.
286+
self.impossibleActivationStateTransition(state: self.activationState, activating: false, context: context)
287+
}
288+
180289
}
181290

182291
public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
@@ -699,8 +808,38 @@ NIOHTTP2Handler(
699808
mode: \(String(describing: self.mode)),
700809
initialSettings: \(String(describing: self.initialSettings)),
701810
channelClosed: \(String(describing: self.channelClosed)),
702-
channelWritable: \(String(describing: self.channelWritable))
811+
channelWritable: \(String(describing: self.channelWritable)),
812+
activationState: \(String(describing: self.activationState))
703813
)
704814
"""
705815
}
706816
}
817+
818+
extension NIOHTTP2Handler {
819+
/// Tracks the state of activation of the handler.
820+
enum ActivationState {
821+
/// The handler hasn't been activated yet.
822+
case idle
823+
824+
/// The handler has received channelActive, but hasn't yet fired it on.
825+
case activating
826+
827+
/// The handler was activating when it received channelInactive. The channel
828+
/// must go inactive after firing channel active.
829+
case inactiveWhileActivating
830+
831+
/// The channel has received and fired channelActive.
832+
case activated
833+
834+
/// The channel has received and fired channelActive and channelInactive.
835+
case inactive
836+
}
837+
838+
fileprivate func impossibleActivationStateTransition(
839+
state: ActivationState, activating: Bool, context: ChannelHandlerContext
840+
) {
841+
assert(self.tolerateImpossibleStateTransitionsInDebugMode, "Unexpected channelActive in state \(state)")
842+
context.fireErrorCaught(NIOHTTP2Errors.ActivationError(state: state, activating: activating))
843+
context.close(promise: nil)
844+
}
845+
}

Sources/NIOHTTP2/HTTP2Error.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,25 @@ public enum NIOHTTP2Errors {
16221622
self.storage = .init(streamID: streamID, baseError: baseError)
16231623
}
16241624
}
1625+
1626+
public struct ActivationError: NIOHTTP2Error, CustomStringConvertible {
1627+
private let state: NIOHTTP2Handler.ActivationState
1628+
1629+
private var activating: Bool
1630+
1631+
init(state: NIOHTTP2Handler.ActivationState, activating: Bool) {
1632+
self.state = state
1633+
self.activating = activating
1634+
}
1635+
1636+
public var description: String {
1637+
if self.activating {
1638+
return "Error during activation: in state \(self.state)"
1639+
} else {
1640+
return "Error during inactivation: in state \(self.state)"
1641+
}
1642+
}
1643+
}
16251644
}
16261645

16271646

Tests/NIOHTTP2Tests/HTTP2FramePayloadStreamMultiplexerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2067,7 +2067,7 @@ final class HTTP2FramePayloadStreamMultiplexerTests: XCTestCase {
20672067
}
20682068
}
20692069

2070-
private final class ErrorRecorder: ChannelInboundHandler {
2070+
final class ErrorRecorder: ChannelInboundHandler {
20712071
typealias InboundIn = Any
20722072

20732073
var errors: [Error] = []

Tests/NIOHTTP2Tests/ReentrancyTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ final class ReentrancyTests: XCTestCase {
149149
try self.serverChannel.assertReceivedFrame().assertFrameMatches(this: settingsFrame)
150150

151151
XCTAssertNoThrow(try self.clientChannel.finish())
152-
XCTAssertNoThrow(try self.serverChannel.finish())
153152
}
154153

155154
func testReenterReadEOFOnRead() throws {

Tests/NIOHTTP2Tests/SimpleClientServerTests+XCTest.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ extension SimpleClientServerTests {
3333
("testNoStreamWindowUpdateOnEndStreamFrameFromServer", testNoStreamWindowUpdateOnEndStreamFrameFromServer),
3434
("testNoStreamWindowUpdateOnEndStreamFrameFromClient", testNoStreamWindowUpdateOnEndStreamFrameFromClient),
3535
("testStreamCreationOrder", testStreamCreationOrder),
36+
("testHandlingChannelInactiveDuringActive", testHandlingChannelInactiveDuringActive),
37+
("testWritingFromChannelActiveIsntReordered", testWritingFromChannelActiveIsntReordered),
38+
("testChannelActiveAfterAddingToActivePipelineDoesntError", testChannelActiveAfterAddingToActivePipelineDoesntError),
39+
("testImpossibleStateTransitionsThrowErrors", testImpossibleStateTransitionsThrowErrors),
3640
]
3741
}
3842
}

0 commit comments

Comments
 (0)