Skip to content

Add a server connection manager#532

Merged
aryan-25 merged 27 commits intoapple:mainfrom
aryan-25:server-connection-manager
Feb 18, 2026
Merged

Add a server connection manager#532
aryan-25 merged 27 commits intoapple:mainfrom
aryan-25:server-connection-manager

Conversation

@aryan-25
Copy link
Contributor

@aryan-25 aryan-25 commented Feb 3, 2026

Motivation:

NIOHTTP2Handler currently does not support graceful shutdown because it does not action upon a ChannelShouldQuiesceEvent event. See issue #336.

Per the comment on that issue, gRPC-swift-nio-transport implements this functionality in a separate channel handler, named ServerConnectionManagementHandler. We should introduce this to this repo too.

Modifications:

  • Added ServerConnectionManagementHandler from gRPC-swift-nio-transport with some slight modifications such as removing gRPC-specific functionality like policing client pings.
  • Added a new configureAsyncHTTP2Pipeline helper, which takes the connection manager handler as an argument and sets it up alongside NIOHTTP2Handler.

Result:

Addresses issue #336.

@aryan-25 aryan-25 added the 🆕 semver/minor Adds new public API. label Feb 3, 2026
Copy link
Contributor

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add the unmodified code from gRPC as the first commit in this PR to make it easier to see the changes you've made?

@aryan-25 aryan-25 force-pushed the server-connection-manager branch from edf43a7 to ce91534 Compare February 5, 2026 10:00
/// While NIO's `EmbeddedEventLoop` provides control over its view of time (and therefore any
/// events scheduled on it) it doesn't offer a way to get the current time. This is usually done
/// via `NIODeadline`.
public struct Clock {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would err on the side of caution and keep this internal/package because it was the minimal API required to enable various tests in gRPC. We can always think a bit harder about the API and make it public in the future if necessary.

/// - keepaliveConfiguration: Configuration for keep-alive ping behavior. Defaults to `nil`, disabling keep-alive
/// pings.
/// - clock: A clock providing the current time.
public init(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend adding a config struct for all of these values: it will make API evolution much easier (e.g. if more config is added later). Also means you can provide a set of sensible default values as a static computed property on that config too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I've also added a default configuration property in 92aa96e:

public static var defaults: Self {
    Self(
        maxIdleTime: nil,
        maxAge: nil,
        maxGraceTime: .minutes(5),
        keepalive: .init(pingInterval: .hours(2), ackTimeout: .seconds(20))
    )
}

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func configureAsyncHTTP2Pipeline<Output: Sendable>(
mode: NIOHTTP2Handler.ParserMode,
connectionManager: NIOHTTP2ServerConnectionManagementHandler,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing in the handler is a bit odd. It'd be more idiomatic to pass in the config for the handler and have this func create the handler for the caller.

}
}

extension Timer: NIOScheduledCallbackHandler, @unchecked Sendable where Handler: Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unchecked sendable seems wrong: it relies on correct use. Can we make it actually Sendable?

}

/// A manual clock for testing that allows explicit control over time.
public final class Manual {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These publics don’t do anything, so we can safely remove them.

}

public func handlerAdded(context: ChannelHandlerContext) {
assert(context.eventLoop === self.eventLoop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace this assert with an in-event-loop assertion.


// Timer handler views.
extension NIOHTTP2ServerConnectionManagementHandler {
struct MaxIdleTimerHandlerView: @unchecked Sendable, NIOScheduledCallbackHandler {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s the need for these to be Sendable? If we do actually require it, we should fully enforce it using proper preconditions or loop bounds.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Timer, we call the EventLoop/scheduleCallback(at:handler:) function with the handler argument set to the view.

The scheduleCallback function requires handler to be Sendable, which is why we need to make these views Sendable.


extension NIOHTTP2ServerConnectionManagementHandler {
/// A delegate for receiving HTTP/2 stream lifecycle events.
public struct HTTP2StreamDelegate: @unchecked Sendable, NIOHTTP2StreamDelegate {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this public? Same note here re sendability as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIOHTTP2ServerConnectionManagementHandler will only know when streams are opened / closed via its delegate; users need to pass this stream delegate to NIOHTTP2Handler for NIOHTTP2ServerConnectionManagementHandler to work as intended.

This reason this is public is so users can also use NIOHTTP2ServerConnectionManagementHandler without this pipeline setup helper.

}

/// Configuration parameters for ``NIOHTTP2ServerConnectionManagementHandler``.
public struct Configuration: Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a sufficiently large range of configuration knobs that it might be nice for us to add some narrative documentation explaining what this thing is for, how it works, and when you might use it.

/// - pingInterval: The amount of time to wait after reading data before sending a keep-alive ping.
/// - ackTimeout: The amount of time the client has to reply after the server sends a keep-alive ping to keep
/// the connection open. The connection is closed if no reply is received.
public init(pingInterval: TimeAmount, ackTimeout: TimeAmount = .seconds(20)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the principle behind defaulting only the second parameter?

Copy link
Contributor Author

@aryan-25 aryan-25 Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In gRPC-swift-nio-transport where this connection manager is derived from, both the pingInterval and ackTimeout arguments are contained in the top-level config type, not in a standalone Keepalive type like in this PR.

It is possible (in gRPC-swift-nio-transport) to set pingInterval to a non-nil value and ackTimeout to nil. In that case, a default of 20 seconds is used for ackTimeout.

I can remove the default argument for ackTimeout here since that case does not arise: users must define an ackTimeout when pingTimeout is non-nil.


/// Start or restart the timer.
mutating func start() {
self.eventLoop.assertInEventLoop()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions are weird now that this type might be Sendable. Are they actually required in any way for correct function? It seems like probably not.

context.fireUserInboundEventTriggered(event)
}

public func errorCaught(context: ChannelHandlerContext, error: any Error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it appropriate for us to suppress errors here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not be determining whether to close the connection here, and just be propagating the error down the pipeline.

I've removed the errorCaught function entirely.

self.inReadLoop = false

// Done reading: schedule the keep-alive timer.
self.keepaliveTimerHandler?.start()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this logic entirely right? We can get a channelReadComplete with no actual read data getting to us here. Is the semantic that we need a read of anything from the network, even if it never produced a frame, or do we need a complete frame?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think any activity on the network originating from the client is sufficient for us to reset the keepalive timer.

@glbrntt, what are your thoughts on this? The gRPC proposal, which I believe the connection manager implementation in gRPC-swift-nio-transport closely follows, states this:

"keepalive time is ideally measured from the time of the last byte read"

Note: The above is actually from the client-side connection management proposal (the server-side proposal refers readers to the client-side proposal for implementing keepalive checks (see this)).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need a complete frame, just any bytes on the network if we're following the gRPC semantics.

// Timer handler views.
extension NIOHTTP2ServerConnectionManagementHandler {
struct MaxIdleTimerHandlerView: Sendable, NIOScheduledCallbackHandler {
private let handler: NIOLoopBound<NIOHTTP2ServerConnectionManagementHandler>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still not clear to me why these views need to be Sendable. There's no requirement for it that I can see.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I forgot to answer this in your previous comment. See #532 (comment).

@aryan-25 aryan-25 requested review from Lukasa and glbrntt February 13, 2026 14:02
Comment on lines +128 to +129
/// Send a GOAWAY frame with the code "enhance your calm" and immediately close the connection.
case enhanceYourCalmThenClose(HTTP2StreamID)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never used because the keepalive policing was removed

Comment on lines +123 to +142
/// Creates a clock with a manual time source for testing.
/// - Parameter time: The manual time source.
/// - Returns: A clock that uses the provided manual time source.
static func manual(_ time: Manual) -> Self {
Self(base: .manual(time))
}

/// A manual clock for testing that allows explicit control over time.
final class Manual {
private(set) var time: NIODeadline

/// Creates a manual clock with time starting at zero.
init() {
self.time = .uptimeNanoseconds(0)
}

func advance(by amount: TimeAmount) {
self.time = self.time + amount
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is never used, even by the tests.

Copy link
Contributor

@glbrntt glbrntt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM although it'd be good to add the note about why we can ignore pings (but not ping acks)

self.handlePingAck(context: context, data: data)

default:
() // Only interested in PING frames with the ACK flag set, ignore the rest.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth adding a note here that the HTTP2Handler acks PING frames and that's why we can ignore them here.

@aryan-25 aryan-25 merged commit b6571f3 into apple:main Feb 18, 2026
50 checks passed
@aryan-25 aryan-25 linked an issue Feb 19, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Adds new public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NIOHTTP2Handler should handle ChannelShouldQuiesceEvent

3 participants