Skip to content

Commit 345438e

Browse files
authored
Avoid a double-idle in the idle handler. (#875)
Motivation: The idle connectivity state can be achieved in two ways: 1. We have no active streams and receive a GOAWAY frame, and 2. We have no active streams and the idle timeout elapses. The only valid state we can be in to transition to idle is ready (i.e. we have an active channel and have received the first SETTINGS frame). We don't currently protect against going idle twice: that is, receiving a GOAWAY and subequently having the timeout fire. This leads to an invalid state transition (idle to idle). Modifications: - Check our 'readiness' state in the idle handler before calling 'idle()' on the connection manager - Add a test. - Alseo cancel the timeout when the handler is removed. Result: We avoid invalid state transitions when double-idling.
1 parent 4e91a40 commit 345438e

File tree

3 files changed

+75
-7
lines changed

3 files changed

+75
-7
lines changed

Sources/GRPC/GRPCIdleHandler.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ internal class GRPCIdleHandler: ChannelInboundHandler {
9797
context.fireChannelActive()
9898
}
9999

100+
func handlerRemoved(context: ChannelHandlerContext) {
101+
self.scheduledIdle?.cancel()
102+
}
103+
100104
func channelInactive(context: ChannelHandlerContext) {
101105
self.scheduledIdle?.cancel()
102106
self.scheduledIdle = nil
@@ -166,18 +170,26 @@ internal class GRPCIdleHandler: ChannelInboundHandler {
166170
}
167171

168172
private func idle(context: ChannelHandlerContext) {
173+
// Don't idle if there are active streams.
169174
guard self.activeStreams == 0 else {
170175
return
171176
}
172177

173-
self.state = .closed
174-
switch self.mode {
175-
case .client(let manager):
176-
manager.idle()
177-
case .server:
178+
switch self.state {
179+
case .notReady, .ready:
180+
self.state = .closed
181+
switch self.mode {
182+
case .client(let manager):
183+
manager.idle()
184+
case .server:
185+
()
186+
}
187+
context.close(mode: .all, promise: nil)
188+
189+
// We need to guard against double closure here. We may go idle as a result of receiving a
190+
// GOAWAY frame or because our scheduled idle timeout fired.
191+
case .closed:
178192
()
179193
}
180-
181-
context.close(mode: .all, promise: nil)
182194
}
183195
}

Tests/GRPCTests/ConnectionManagerTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,61 @@ extension ConnectionManagerTests {
687687
self.loop.run()
688688
XCTAssertThrowsError(try channel.wait())
689689
}
690+
691+
func testDoubleIdle() throws {
692+
class CloseDroppingHandler: ChannelOutboundHandler {
693+
typealias OutboundIn = Any
694+
func close(context: ChannelHandlerContext, mode: CloseMode, promise: EventLoopPromise<Void>?) {
695+
promise?.fail(GRPCStatus(code: .unavailable, message: "Purposefully dropping channel close"))
696+
}
697+
}
698+
699+
let channelPromise = self.loop.makePromise(of: Channel.self)
700+
let manager = ConnectionManager.testingOnly(configuration: self.defaultConfiguration, logger: self.logger) {
701+
return channelPromise.futureResult
702+
}
703+
704+
// Start the connection.
705+
let readyChannel: EventLoopFuture<Channel> = self.waitForStateChange(from: .idle, to: .connecting) {
706+
let readyChannel = manager.getChannel()
707+
self.loop.run()
708+
return readyChannel
709+
}
710+
711+
// Setup the real channel and activate it.
712+
let channel = EmbeddedChannel(loop: self.loop)
713+
XCTAssertNoThrow(try channel.pipeline.addHandlers([
714+
CloseDroppingHandler(),
715+
GRPCIdleHandler(mode: .client(manager))
716+
]).wait())
717+
channelPromise.succeed(channel)
718+
self.loop.run()
719+
XCTAssertNoThrow(try channel.connect(to: SocketAddress(unixDomainSocketPath: "/ignored")).wait())
720+
721+
// Write a SETTINGS frame on the root stream.
722+
try self.waitForStateChange(from: .connecting, to: .ready) {
723+
let frame = HTTP2Frame(streamID: .rootStream, payload: .settings(.settings([])))
724+
XCTAssertNoThrow(try channel.writeInbound(frame))
725+
}
726+
727+
// The channel should now be ready.
728+
XCTAssertNoThrow(try readyChannel.wait())
729+
730+
// Send a GO_AWAY; the details don't matter. This will cause the connection to go idle and the
731+
// channel to close.
732+
try self.waitForStateChange(from: .ready, to: .idle) {
733+
let goAway = HTTP2Frame(
734+
streamID: .rootStream,
735+
payload: .goAway(lastStreamID: 1, errorCode: .noError, opaqueData: nil)
736+
)
737+
XCTAssertNoThrow(try channel.writeInbound(goAway))
738+
}
739+
740+
// We dropped the close; now wait for the scheduled idle to fire.
741+
//
742+
// Previously doing this this would fail a precondition.
743+
self.loop.advanceTime(by: .minutes(5))
744+
}
690745
}
691746

692747
internal struct Change: Hashable, CustomStringConvertible {

Tests/GRPCTests/XCTestManifests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ extension ConnectionManagerTests {
154154
("testConnectOnSecondAttempt", testConnectOnSecondAttempt),
155155
("testDoomedOptimisticChannelFromConnecting", testDoomedOptimisticChannelFromConnecting),
156156
("testDoomedOptimisticChannelFromIdle", testDoomedOptimisticChannelFromIdle),
157+
("testDoubleIdle", testDoubleIdle),
157158
("testGoAwayWhenReady", testGoAwayWhenReady),
158159
("testIdleShutdown", testIdleShutdown),
159160
("testIdleTimeoutWhenThereAreActiveStreams", testIdleTimeoutWhenThereAreActiveStreams),

0 commit comments

Comments
 (0)