Skip to content

Commit fec325b

Browse files
authored
Close channel on errors (#16)
Motivation: NIO channel pipeline's should, in most cases, be closed when an error is caught. This is not currently the case for the client and server connection channels. Modifications: - Close on error unless it's safe to ignore the error (such as if it's a stream-level error) Result: Connections are closed if something unrecoverable happens
1 parent a0ff9bb commit fec325b

File tree

6 files changed

+146
-30
lines changed

6 files changed

+146
-30
lines changed

Sources/GRPCNIOTransportCore/Client/Connection/ClientConnectionHandler.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,25 @@ package final class ClientConnectionHandler: ChannelInboundHandler, ChannelOutbo
163163
}
164164

165165
package func errorCaught(context: ChannelHandlerContext, error: any Error) {
166-
// Store the error and close, this will result in the final close event being fired down
167-
// the pipeline with an appropriate close reason and appropriate error. (This avoids
168-
// the async channel just throwing the error.)
169-
self.state.receivedError(error)
170-
context.close(mode: .all, promise: nil)
166+
if self.closeConnectionOnError(error) {
167+
// Store the error and close, this will result in the final close event being fired down
168+
// the pipeline with an appropriate close reason and appropriate error. (This avoids
169+
// the async channel just throwing the error.)
170+
self.state.receivedError(error)
171+
context.close(mode: .all, promise: nil)
172+
}
173+
}
174+
175+
private func closeConnectionOnError(_ error: any Error) -> Bool {
176+
switch error {
177+
case is NIOHTTP2Errors.StreamError:
178+
// Stream errors occur in streams, they are only propagated down the connection channel
179+
// pipeline for vestigial reasons.
180+
return false
181+
default:
182+
// Everything else is considered terminal for the connection until we know better.
183+
return true
184+
}
171185
}
172186

173187
package func channelRead(context: ChannelHandlerContext, data: NIOAny) {

Sources/GRPCNIOTransportCore/Server/Connection/ServerConnectionManagementHandler+StateMachine.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ extension ServerConnectionManagementHandler {
3030
/// as part of graceful shutdown.
3131
private let goAwayPingData: HTTP2PingData
3232

33+
/// Whether the connection is currently closing.
34+
var isClosing: Bool {
35+
self.state.isClosing
36+
}
37+
3338
/// Create a new state machine.
3439
///
3540
/// - Parameters:
@@ -391,5 +396,14 @@ extension ServerConnectionManagementHandler.StateMachine {
391396
case closing(Closing)
392397
case closed
393398
case _modifying
399+
400+
var isClosing: Bool {
401+
switch self {
402+
case .closing:
403+
return true
404+
case .active, .closed, ._modifying:
405+
return false
406+
}
407+
}
394408
}
395409
}

Sources/GRPCNIOTransportCore/Server/Connection/ServerConnectionManagementHandler.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,37 @@ package final class ServerConnectionManagementHandler: ChannelDuplexHandler {
314314
context.fireUserInboundEventTriggered(event)
315315
}
316316

317+
package func errorCaught(context: ChannelHandlerContext, error: any Error) {
318+
if self.closeConnectionOnError(error) {
319+
context.close(mode: .all, promise: nil)
320+
}
321+
}
322+
323+
private func closeConnectionOnError(_ error: any Error) -> Bool {
324+
switch error {
325+
case is NIOHTTP2Errors.NoSuchStream:
326+
// In most cases this represents incorrect client behaviour. However, NIOHTTP2 currently
327+
// emits this error if a server receives a HEADERS frame for a new stream after having sent
328+
// a GOAWAY frame. This can happen when a client opening a stream races with a server
329+
// shutting down.
330+
//
331+
// This should be resolved in NIOHTTP2: https://github.com/apple/swift-nio-http2/issues/466
332+
//
333+
// Only close the connection if it's not already closing (as this is the state in which the
334+
// error can be safely ignored).
335+
return !self.state.isClosing
336+
337+
case is NIOHTTP2Errors.StreamError:
338+
// Stream errors occur in streams, they are only propagated down the connection channel
339+
// pipeline for vestigial reasons.
340+
return false
341+
342+
default:
343+
// Everything else is considered terminal for the connection until we know better.
344+
return true
345+
}
346+
}
347+
317348
package func channelRead(context: ChannelHandlerContext, data: NIOAny) {
318349
self.inReadLoop = true
319350

Tests/GRPCNIOTransportCoreTests/Client/Connection/ClientConnectionHandlerTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,54 @@ struct ClientConnectionHandlerTests {
324324
connection.channel.close(mode: .all, promise: nil)
325325
#expect(try connection.readEvent() == .closing(.unexpected(nil, isIdle: false)))
326326
}
327+
328+
@Test("Closes on error")
329+
func closesOnError() throws {
330+
let connection = try Connection()
331+
try connection.activate()
332+
333+
let streamError = NIOHTTP2Errors.noSuchStream(streamID: 42)
334+
connection.channel.pipeline.fireErrorCaught(streamError)
335+
336+
// Closing is completed on the next loop tick, so run the loop.
337+
connection.channel.embeddedEventLoop.run()
338+
try connection.channel.closeFuture.wait()
339+
}
340+
341+
@Test("Doesn't close on stream error")
342+
func doesNotCloseOnStreamError() throws {
343+
let connection = try Connection(maxIdleTime: .minutes(1))
344+
try connection.activate()
345+
346+
let streamError = NIOHTTP2Errors.streamError(
347+
streamID: 42,
348+
baseError: NIOHTTP2Errors.streamIDTooSmall()
349+
)
350+
connection.channel.pipeline.fireErrorCaught(streamError)
351+
352+
// Now do a normal shutdown to make sure the connection is still working as normal.
353+
//
354+
// Write the initial settings to ready the connection.
355+
try connection.settings([])
356+
#expect(try connection.readEvent() == .ready)
357+
358+
// Idle with no streams open we should:
359+
// - read out a closing event,
360+
// - write a GOAWAY frame,
361+
// - close.
362+
connection.loop.advanceTime(by: .minutes(5))
363+
364+
#expect(try connection.readEvent() == .closing(.idle))
365+
366+
let frame = try #require(try connection.readFrame())
367+
#expect(frame.streamID == .rootStream)
368+
let (lastStreamID, error, data) = try #require(frame.payload.goAway)
369+
#expect(lastStreamID == .rootStream)
370+
#expect(error == .noError)
371+
#expect(data == ByteBuffer(string: "idle"))
372+
373+
try connection.waitUntilClosed()
374+
}
327375
}
328376

329377
extension ClientConnectionHandlerTests {

Tests/GRPCNIOTransportCoreTests/XCTest+FramePayload.swift renamed to Tests/GRPCNIOTransportCoreTests/HTTP2Frame+Helpers.swift

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,6 @@
1616

1717
import NIOCore
1818
import NIOHTTP2
19-
import XCTest
20-
21-
func XCTAssertGoAway(
22-
_ payload: HTTP2Frame.FramePayload,
23-
verify: (HTTP2StreamID, HTTP2ErrorCode, ByteBuffer?) throws -> Void = { _, _, _ in }
24-
) rethrows {
25-
switch payload {
26-
case .goAway(let lastStreamID, let errorCode, let opaqueData):
27-
try verify(lastStreamID, errorCode, opaqueData)
28-
default:
29-
XCTFail("Expected '.goAway' got '\(payload)'")
30-
}
31-
}
32-
33-
func XCTAssertPing(
34-
_ payload: HTTP2Frame.FramePayload,
35-
verify: (HTTP2PingData, Bool) throws -> Void = { _, _ in }
36-
) rethrows {
37-
switch payload {
38-
case .ping(let data, ack: let ack):
39-
try verify(data, ack)
40-
default:
41-
XCTFail("Expected '.ping' got '\(payload)'")
42-
}
43-
}
4419

4520
extension HTTP2Frame.FramePayload {
4621
var goAway: (lastStreamID: HTTP2StreamID, errorCode: HTTP2ErrorCode, opaqueData: ByteBuffer?)? {

Tests/GRPCNIOTransportCoreTests/Server/Connection/ServerConnectionManagementHandlerTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,40 @@ struct ServerConnectionManagementHandlerTests {
292292
// The server should close the connection.
293293
try connection.waitUntilClosed()
294294
}
295+
296+
@Test("Closes on error")
297+
func closesOnError() throws {
298+
let connection = try Connection()
299+
try connection.activate()
300+
301+
let streamError = NIOHTTP2Errors.noSuchStream(streamID: 42)
302+
connection.channel.pipeline.fireErrorCaught(streamError)
303+
304+
// Closing is completed on the next loop tick, so run the loop.
305+
connection.channel.embeddedEventLoop.run()
306+
try connection.channel.closeFuture.wait()
307+
}
308+
309+
@Test("Doesn't close on stream error")
310+
func doesNotCloseOnStreamError() throws {
311+
let connection = try Connection(maxIdleTime: .minutes(1))
312+
try connection.activate()
313+
314+
let streamError = NIOHTTP2Errors.streamError(
315+
streamID: 42,
316+
baseError: NIOHTTP2Errors.streamIDTooSmall()
317+
)
318+
connection.channel.pipeline.fireErrorCaught(streamError)
319+
320+
// Follow a normal flow to check the connection wasn't closed.
321+
//
322+
// Hit the max idle time.
323+
connection.advanceTime(by: .minutes(1))
324+
// Follow the graceful shutdown flow.
325+
try self.testGracefulShutdown(connection: connection, lastStreamID: 0)
326+
// Closed because no streams were open.
327+
try connection.waitUntilClosed()
328+
}
295329
}
296330

297331
extension ServerConnectionManagementHandlerTests {

0 commit comments

Comments
 (0)