From e0fa7b9ecca6607dcd5ed45a654370aa20834fd6 Mon Sep 17 00:00:00 2001 From: Eric Rosenberg Date: Mon, 16 Dec 2024 19:58:58 +0000 Subject: [PATCH 1/3] support h2 stream resets through user events Resetting streams with specific error codes is required by some applications such as those implementing the CONNECT method (see https://datatracker.ietf.org/doc/html/rfc9113#section-8.5-8). Unfortunately, the HTTP2ToHTTP codecs don't expose this capability to applications. This change introduces an outbound user event applications can trigger when needing to reset a stream. --- .../NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift | 30 +++++++++++++++++++ .../NIOHTTPTypesHTTP2Tests.swift | 14 +++++++++ 2 files changed, 44 insertions(+) diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift index 10c81adb..a0731861 100644 --- a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift @@ -152,6 +152,14 @@ public final class HTTP2FramePayloadToHTTPClientCodec: ChannelDuplexHandler, Rem context.fireErrorCaught(error) } } + + public func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + if let ev = event as? HTTP2FramePayloadToHTTPEvent, case .reset(let code) = ev.kind { + context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) + return + } + context.triggerUserOutboundEvent(event, promise: promise) + } } // MARK: - Server @@ -262,4 +270,26 @@ public final class HTTP2FramePayloadToHTTPServerCodec: ChannelDuplexHandler, Rem let transformedPayload = self.baseCodec.processOutboundData(responsePart, allocator: context.channel.allocator) context.write(self.wrapOutboundOut(transformedPayload), promise: promise) } + + public func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + if let ev = event as? HTTP2FramePayloadToHTTPEvent, case .reset(let code) = ev.kind { + context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) + return + } + context.triggerUserOutboundEvent(event, promise: promise) + } } + +/// Events that can be sent by the application to be handled by the `HTTP2StreamChannel` +public struct HTTP2FramePayloadToHTTPEvent { + fileprivate enum Kind { + case reset(HTTP2ErrorCode) + } + + fileprivate var kind: Kind + + /// Send a `RST_STREAM` with the specified code + public static func reset(code: HTTP2ErrorCode) -> Self { + .init(kind: .reset(code)) + } +} \ No newline at end of file diff --git a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift index 769b7255..ea593a5e 100644 --- a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift +++ b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift @@ -116,9 +116,16 @@ final class NIOHTTPTypesHTTP2Tests: XCTestCase { try self.channel.writeOutbound(HTTPRequestPart.head(Self.request)) try self.channel.writeOutbound(HTTPRequestPart.end(Self.trailers)) + try self.channel.triggerUserOutboundEvent(HTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldRequest) XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) + switch try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self) { + case .rstStream(.enhanceYourCalm): + break + default: + XCTFail("expected reset") + } try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldResponse)) try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldTrailers)) @@ -142,9 +149,16 @@ final class NIOHTTPTypesHTTP2Tests: XCTestCase { try self.channel.writeOutbound(HTTPResponsePart.head(Self.response)) try self.channel.writeOutbound(HTTPResponsePart.end(Self.trailers)) + try self.channel.triggerUserOutboundEvent(HTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldResponse) XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) + switch try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self) { + case .rstStream(.enhanceYourCalm): + break + default: + XCTFail("expected reset") + } XCTAssertTrue(try self.channel.finish().isClean) } From 640578e46fabd71f1347cdf3e6397c515dabb631 Mon Sep 17 00:00:00 2001 From: Eric Rosenberg Date: Tue, 17 Dec 2024 19:50:20 +0000 Subject: [PATCH 2/3] Allow inspection of event to support testing --- Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift index a0731861..309212ad 100644 --- a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift @@ -292,4 +292,12 @@ public struct HTTP2FramePayloadToHTTPEvent { public static func reset(code: HTTP2ErrorCode) -> Self { .init(kind: .reset(code)) } + + /// Returns reset code if the event is a reset + public func reset() -> HTTP2ErrorCode? { + if case let .reset(code) = self.kind { + return code + } + return nil + } } \ No newline at end of file From 39e19e0d48c9a6874629c489eca2b74f02e8d36a Mon Sep 17 00:00:00 2001 From: Eric Rosenberg Date: Thu, 19 Dec 2024 14:27:22 +0000 Subject: [PATCH 3/3] feedback --- .../NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift | 26 +++++++++---------- .../NIOHTTPTypesHTTP2Tests.swift | 20 +++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift index 309212ad..51575633 100644 --- a/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift +++ b/Sources/NIOHTTPTypesHTTP2/HTTP2ToHTTPCodec.swift @@ -154,9 +154,9 @@ public final class HTTP2FramePayloadToHTTPClientCodec: ChannelDuplexHandler, Rem } public func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { - if let ev = event as? HTTP2FramePayloadToHTTPEvent, case .reset(let code) = ev.kind { - context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) - return + if let ev = event as? NIOHTTP2FramePayloadToHTTPEvent, let code = ev.reset { + context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) + return } context.triggerUserOutboundEvent(event, promise: promise) } @@ -272,21 +272,21 @@ public final class HTTP2FramePayloadToHTTPServerCodec: ChannelDuplexHandler, Rem } public func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { - if let ev = event as? HTTP2FramePayloadToHTTPEvent, case .reset(let code) = ev.kind { - context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) - return + if let ev = event as? NIOHTTP2FramePayloadToHTTPEvent, let code = ev.reset { + context.writeAndFlush(self.wrapOutboundOut(.rstStream(code)), promise: promise) + return } context.triggerUserOutboundEvent(event, promise: promise) } } /// Events that can be sent by the application to be handled by the `HTTP2StreamChannel` -public struct HTTP2FramePayloadToHTTPEvent { - fileprivate enum Kind { +public struct NIOHTTP2FramePayloadToHTTPEvent: Hashable, Sendable { + private enum Kind: Hashable, Sendable { case reset(HTTP2ErrorCode) } - fileprivate var kind: Kind + private var kind: Kind /// Send a `RST_STREAM` with the specified code public static func reset(code: HTTP2ErrorCode) -> Self { @@ -294,10 +294,10 @@ public struct HTTP2FramePayloadToHTTPEvent { } /// Returns reset code if the event is a reset - public func reset() -> HTTP2ErrorCode? { - if case let .reset(code) = self.kind { + public var reset: HTTP2ErrorCode? { + switch self.kind { + case .reset(let code): return code } - return nil } -} \ No newline at end of file +} diff --git a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift index ea593a5e..8030ba5a 100644 --- a/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift +++ b/Tests/NIOHTTPTypesHTTP2Tests/NIOHTTPTypesHTTP2Tests.swift @@ -116,15 +116,15 @@ final class NIOHTTPTypesHTTP2Tests: XCTestCase { try self.channel.writeOutbound(HTTPRequestPart.head(Self.request)) try self.channel.writeOutbound(HTTPRequestPart.end(Self.trailers)) - try self.channel.triggerUserOutboundEvent(HTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() + try self.channel.triggerUserOutboundEvent(NIOHTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldRequest) XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) switch try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self) { - case .rstStream(.enhanceYourCalm): - break - default: - XCTFail("expected reset") + case .rstStream(.enhanceYourCalm): + break + default: + XCTFail("expected reset") } try self.channel.writeInbound(HTTP2Frame.FramePayload(headers: Self.oldResponse)) @@ -149,15 +149,15 @@ final class NIOHTTPTypesHTTP2Tests: XCTestCase { try self.channel.writeOutbound(HTTPResponsePart.head(Self.response)) try self.channel.writeOutbound(HTTPResponsePart.end(Self.trailers)) - try self.channel.triggerUserOutboundEvent(HTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() + try self.channel.triggerUserOutboundEvent(NIOHTTP2FramePayloadToHTTPEvent.reset(code: .enhanceYourCalm)).wait() XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldResponse) XCTAssertEqual(try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self)?.headers, Self.oldTrailers) switch try self.channel.readOutbound(as: HTTP2Frame.FramePayload.self) { - case .rstStream(.enhanceYourCalm): - break - default: - XCTFail("expected reset") + case .rstStream(.enhanceYourCalm): + break + default: + XCTFail("expected reset") } XCTAssertTrue(try self.channel.finish().isClean)