Skip to content

Commit 2ca31f0

Browse files
authored
Wait for writes to drain before closing (#183)
Motivation: The server can drop pending writes during graceful shutdown. The connection management handler waits for signals from SwiftNIO to determine which streams are currently open, and importantly for graceful shutdown, when all streams are closed. However these notifications happen when the stream is closed in NIOs HTTP/2 state machine, not when all data for that stream has been written. This is problematic if there peer is waiting for a WINDOW_UPDATE or the bytes haven't yet been flushed. Modifications: - When the stream count drops to zero, half-close the connection. - When half-close complete (and thus all previously comitted data has been written) full close the connection. Result: Fewer failed RPCs
1 parent 72142e6 commit 2ca31f0

2 files changed

Lines changed: 99 additions & 16 deletions

File tree

Sources/GRPCNIOTransportCore/Server/Connection/ServerConnectionManagementHandler.swift

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -537,22 +537,14 @@ extension ServerConnectionManagementHandler {
537537
case .startIdleTimer:
538538
self.maxIdleTimerHandler?.start()
539539
case .close:
540-
// Defer closing until the next tick of the event loop.
540+
// Defer until the next tick so the HTTP/2 handler's pending flush (which carries
541+
// the END_STREAM frame's bytes) propagates first; then half-close to wait for those
542+
// bytes to drain before fully closing.
541543
//
542-
// This point is reached because the server is shutting down gracefully and the stream count
543-
// has dropped to zero, meaning the connection is no longer required and can be closed.
544-
// However, the stream would've been closed by writing and flushing a frame with end stream
545-
// set. These are two distinct events in the channel pipeline. The HTTP/2 handler updates the
546-
// state machine when a frame is written, which in this case results in the stream closed
547-
// event which we're reacting to here.
548-
//
549-
// Importantly the HTTP/2 handler hasn't yet seen the flush event, so the bytes of the frame
550-
// with end-stream set - and potentially some other frames - are sitting in a buffer in the
551-
// HTTP/2 handler. If we close on this event loop tick then those frames will be dropped.
552-
// Delaying the close by a loop tick will allow the flush to happen before the close.
553-
let loopBound = NIOLoopBound(context, eventLoop: context.eventLoop)
554-
context.eventLoop.execute {
555-
loopBound.value.close(mode: .all, promise: nil)
544+
// Without the half-close barrier any bytes still pending would be dropped on
545+
// full close.
546+
context.eventLoop.assumeIsolated().execute {
547+
Self.gracefullyClose(context: context)
556548
}
557549

558550
case .none:
@@ -654,7 +646,7 @@ extension ServerConnectionManagementHandler {
654646
self.maybeFlush(context: context)
655647

656648
if close {
657-
context.close(promise: nil)
649+
Self.gracefullyClose(context: context)
658650
} else {
659651
// RPCs may have a grace period for finishing once the second GOAWAY frame has finished.
660652
// If this is set close the connection abruptly once the grace period passes.
@@ -666,6 +658,20 @@ extension ServerConnectionManagementHandler {
666658
}
667659
}
668660

661+
/// Closes the connection gracefully.
662+
///
663+
/// `close(mode: .all)` would drop any writes still queued in the channel's pending-writes
664+
/// buffer or the kernel send buffer. Half-closing the output side first acts as a drain
665+
/// barrier: the close-output promise only succeeds once all preceding writes have been
666+
/// written, after which the full close is safe.
667+
private static func gracefullyClose(context: ChannelHandlerContext) {
668+
let promise = context.eventLoop.makePromise(of: Void.self)
669+
context.close(mode: .output, promise: promise)
670+
promise.futureResult.assumeIsolated().whenComplete { _ in
671+
context.close(mode: .all, promise: nil)
672+
}
673+
}
674+
669675
private func keepaliveTimerFired() {
670676
guard let context = self.context else { return }
671677
let ping = HTTP2Frame(streamID: .rootStream, payload: .ping(self.keepalivePingData, ack: false))

Tests/GRPCNIOTransportCoreTests/Server/Connection/ServerConnectionManagementHandlerTests.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,61 @@ struct ServerConnectionManagementHandlerTests {
140140
try connection.waitUntilClosed()
141141
}
142142

143+
@Test("Graceful close half-closes output before full close (no streams)")
144+
@available(gRPCSwiftNIOTransport 2.0, *)
145+
func gracefulCloseHalfClosesBeforeFullCloseWithNoStreams() throws {
146+
let connection = try Connection(maxIdleTime: .minutes(1))
147+
let recorder = CloseRecorder()
148+
try connection.channel.pipeline.syncOperations.addHandler(recorder, position: .first)
149+
try connection.activate()
150+
151+
connection.advanceTime(by: .minutes(1))
152+
try self.testGracefulShutdown(connection: connection, lastStreamID: 0)
153+
154+
// The ping-ack is the trigger for closing output. The recording handler stores the promise
155+
// which will be succeeded in a moment.
156+
#expect(recorder.modes == [.output])
157+
158+
// Complete the half-close promise. Doing that triggers the full close.
159+
recorder.completeOutput()
160+
#expect(recorder.modes == [.output, .all])
161+
162+
try connection.waitUntilClosed()
163+
}
164+
165+
@Test("Graceful close half-closes output before full close (last stream closes)")
166+
@available(gRPCSwiftNIOTransport 2.0, *)
167+
func gracefulCloseHalfClosesBeforeFullCloseOnLastStreamClose() throws {
168+
// Path 2: _streamClosed → .close (PING ACK first; stream closes after second GOAWAY).
169+
let connection = try Connection(maxIdleTime: .minutes(1))
170+
let recorder = CloseRecorder()
171+
try connection.channel.pipeline.syncOperations.addHandler(recorder, position: .first)
172+
try connection.activate()
173+
174+
connection.advanceTime(by: .minutes(1))
175+
try self.testGracefulShutdown(
176+
connection: connection,
177+
lastStreamID: 1,
178+
streamToOpenBeforePingAck: 1
179+
)
180+
181+
// Stream still open after second GOAWAY: no close yet.
182+
#expect(recorder.modes.isEmpty)
183+
184+
// Closing the last stream schedules a graceful close on the event loop.
185+
connection.streamClosed(1)
186+
connection.loop.run()
187+
188+
// The recording handler stores the promise which will be succeeded in a moment.
189+
#expect(recorder.modes == [.output])
190+
191+
// Complete the half-close promise. Doing that triggers the full close.
192+
recorder.completeOutput()
193+
#expect(recorder.modes == [.output, .all])
194+
195+
try connection.waitUntilClosed()
196+
}
197+
143198
@Test("Keepalive works on new connection")
144199
@available(gRPCSwiftNIOTransport 2.0, *)
145200
func keepaliveOnNewConnection() throws {
@@ -480,3 +535,25 @@ extension ServerConnectionManagementHandlerTests {
480535
}
481536
}
482537
}
538+
539+
private final class CloseRecorder: ChannelOutboundHandler {
540+
typealias OutboundIn = NIOAny
541+
542+
private(set) var modes: [CloseMode] = []
543+
private var halfClosePromise: EventLoopPromise<Void>?
544+
545+
func close(context: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise<Void>?) {
546+
self.modes.append(mode)
547+
switch mode {
548+
case .output:
549+
self.halfClosePromise = promise
550+
default:
551+
context.close(mode: mode, promise: promise)
552+
}
553+
}
554+
555+
func completeOutput() {
556+
self.halfClosePromise?.succeed(())
557+
self.halfClosePromise = nil
558+
}
559+
}

0 commit comments

Comments
 (0)