diff --git a/Package.swift b/Package.swift index 81391a4..7bc38bd 100644 --- a/Package.swift +++ b/Package.swift @@ -128,6 +128,17 @@ let package = Package( ], swiftSettings: extraSettings ), + .executableTarget( + name: "StreamResetExample", + dependencies: [ + "ExampleSupport", + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOHTTP2", package: "swift-nio-http2"), + .product(name: "NIOHTTP3", package: "swift-nio-http3", condition: .when(traits: ["HTTP3"])), + "NIOHTTPServer", + ], + swiftSettings: extraSettings + ), .executableTarget( name: "ConnectionHandlerExample", dependencies: [ diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 33c39f3..56ec274 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -459,7 +459,7 @@ public enum CertificateVerificationResult: Sendable, Hashable { } /// An error representing certificate verification failure. - public struct VerificationError: Swift.Error, Hashable { + public struct VerificationError: Swift.Error, Hashable, CustomStringConvertible { public let reason: String /// Creates a verification error with the reason why verification failed. @@ -467,6 +467,10 @@ public enum CertificateVerificationResult: Sendable, Hashable { public init(reason: String) { self.reason = reason } + + public var description: String { + "Verification error: \(self.reason)" + } } /// Certificate verification succeeded. diff --git a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift index 26f7cb6..c2c8f99 100644 --- a/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift +++ b/Sources/NIOHTTPServer/HTTPKeepAliveHandler.swift @@ -71,12 +71,69 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { /// `true` if we've committed to closing the connection after this response's /// `.end` is written. Set when the buffer is flushed while request `.end` has - /// not yet arrived (so we add `Connection: close`). Cleared when a new request + /// not yet arrived, and when a request is aborted (so a still-buffered head + /// picks up `Connection: close` on its way out). Cleared when a new request /// begins. private var closeAfterResponseEnd: Bool = false private var finalResponseState: FinalResponseState = .notStarted + /// Asks the handler to abandon the current response and close the connection. + /// + /// Fired as a user outbound event by the server when a request handler throws. HTTP/1.1 has no per-stream reset, so + /// aborting means closing the connection — and how much of the response can still be salvaged depends on how far it + /// has progressed, which only this handler knows. + struct RequestAborted: Sendable { + /// The response to send when the handler had not started a response yet. + /// + /// It is amended with `Connection: close` before being written. If a response has already been written this is + /// ignored. + var responseIfNotStarted: HTTPResponse + } + + func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + guard let abort = event as? RequestAborted else { + context.triggerUserOutboundEvent(event, promise: promise) + return + } + + self.abortResponse(context: context, responseIfNotStarted: abort.responseIfNotStarted) + promise?.succeed() + } + + /// Abandons the in-flight response and closes the connection. + private func abortResponse(context: ChannelHandlerContext, responseIfNotStarted: HTTPResponse) { + switch self.finalResponseState { + case .notStarted: + // Nothing has been written for this request, so a complete response can still be sent. + var response = responseIfNotStarted + response.headerFields[.connection] = "close" + self.closeAfterResponseEnd = true + self.finalResponseState = .streaming + + context.write(self.wrapOutboundOut(.head(response)), promise: nil) + context.write(self.wrapOutboundOut(.end(nil)), promise: nil) + + case .buffering: + // The head has not reached the wire yet, so it can still be amended with `Connection: close`. + // + // No response `.end` is synthesized: the handler abandoned this response, and for a chunked body `.end` + // writes the terminating chunk, which would tell the client the truncated response was complete. Leaving it + // out means the client sees an incomplete message, which is the signal we want. For a `Content-Length` body + // `.end` writes no bytes at all, so omitting it changes nothing. + self.closeAfterResponseEnd = true + self.flushBuffer(context: context) + + case .streaming: + // The head is already on the wire and cannot be recalled. The response is abandoned, so the client observes + // it as truncated — again without a fabricated `.end`. + () + } + + context.flush() + context.close(mode: .output, promise: nil) + } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { let part = self.unwrapInboundIn(data) switch part { @@ -154,12 +211,13 @@ final class HTTPKeepAliveHandler: ChannelDuplexHandler { } /// Releases buffered response parts to the pipeline. If request `.end` has not - /// yet arrived, amend the head with `Connection: close` and arrange to close - /// the connection once response `.end` is written. + /// yet arrived, or we have already committed to closing, amend the head with + /// `Connection: close` and arrange to close the connection once response `.end` + /// is written. private func flushBuffer(context: ChannelHandlerContext) { guard case .buffering(var head, let additional) = self.finalResponseState else { return } - if !self.requestEndReceived { + if self.closeAfterResponseEnd || !self.requestEndReceived { // Amend the head with `Connection: close` before flushing. if case .head(var response) = head.part { response.headerFields[.connection] = "close" diff --git a/Sources/NIOHTTPServer/HTTPServerHTTP2StreamResetErrorConvertible.swift b/Sources/NIOHTTPServer/HTTPServerHTTP2StreamResetErrorConvertible.swift new file mode 100644 index 0000000..f2fcbb2 --- /dev/null +++ b/Sources/NIOHTTPServer/HTTPServerHTTP2StreamResetErrorConvertible.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An error that maps to the `RST_STREAM` error code sent when an HTTP/2 request is aborted. +/// +/// A request handler reports a failure by throwing. The server does not surface that error to any caller: instead it +/// aborts the exchange on the wire, which over HTTP/2 means resetting the request's stream. Conform an error to this +/// protocol to choose the error code carried by that `RST_STREAM` frame. +/// +/// An error that does not conform is reset with `INTERNAL_ERROR` (`0x02`). +/// +/// ## Example +/// +/// A proxy that fails to establish a tunnel reports it as a `CONNECT` error: +/// +/// ```swift +/// struct TunnelFailure: HTTPServerHTTP2StreamResetErrorConvertible { +/// var http2StreamResetCode: UInt32 { 0x0a } // CONNECT_ERROR +/// } +/// +/// try await server.serve { request, context, reader, responseSender in +/// guard let tunnel = try? await openTunnel(to: request.authority) else { +/// throw TunnelFailure() +/// } +/// // ... +/// } +/// ``` +public protocol HTTPServerHTTP2StreamResetErrorConvertible: Error { + /// The `RST_STREAM` error code to send, as its numeric value on the wire. + /// + /// The codes and their values are defined by RFC 9113 § 7 — for example `0x08` for `CANCEL`, `0x0a` for + /// `CONNECT_ERROR`, or `0x02` for `INTERNAL_ERROR`. + /// + /// This code is used only when the request is served over HTTP/2. + var http2StreamResetCode: UInt32 { get } +} diff --git a/Sources/NIOHTTPServer/HTTPServerHTTP3StreamResetErrorConvertible.swift b/Sources/NIOHTTPServer/HTTPServerHTTP3StreamResetErrorConvertible.swift new file mode 100644 index 0000000..7efe049 --- /dev/null +++ b/Sources/NIOHTTPServer/HTTPServerHTTP3StreamResetErrorConvertible.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 +/// An error that maps to the error codes sent when an HTTP/3 request is aborted. +/// +/// A request handler reports a failure by throwing. The server does not surface that error to any caller: instead it +/// aborts the exchange on the wire, which over HTTP/3 means resetting the request's stream and asking the client to +/// stop sending the request body. Conform an error to this protocol to choose the error codes carried by those frames. +/// +/// An error that does not conform is reset with `H3_INTERNAL_ERROR` (`0x0102`). +/// +/// ## Example +/// +/// A proxy that fails to establish a tunnel reports it as a `CONNECT` error: +/// +/// ```swift +/// struct TunnelFailure: HTTPServerHTTP3StreamResetErrorConvertible { +/// var http3StreamResetCode: UInt64 { 0x010f } // H3_CONNECT_ERROR +/// var http3StopSendingCode: UInt64 { 0x010f } // H3_CONNECT_ERROR +/// } +/// +/// try await server.serve { request, context, reader, responseSender in +/// guard let tunnel = try? await openTunnel(to: request.authority) else { +/// throw TunnelFailure() +/// } +/// // ... +/// } +/// ``` +public protocol HTTPServerHTTP3StreamResetErrorConvertible: Error { + /// The application error code to send when abandoning the response, as its numeric value on the wire. + /// + /// The codes and their values are defined by RFC 9114 § 8.1 — for example `0x010f` for `H3_CONNECT_ERROR`, + /// `0x010c` for `H3_REQUEST_CANCELLED`, or `0x0102` for `H3_INTERNAL_ERROR`. + /// + /// The value must be less than 2^62, the largest value the transport can encode; an out-of-range value is replaced + /// with `H3_INTERNAL_ERROR`. This code is used only when the request is served over HTTP/3. + var http3StreamResetCode: UInt64 { get } + + /// The application error code to send when asking the client to stop sending the request body that the server is no + /// longer reading, as its numeric value on the wire. + /// + /// The same code space and range restriction as ``http3StreamResetCode`` applies. + var http3StopSendingCode: UInt64 { get } +} +#endif diff --git a/Sources/NIOHTTPServer/LoggingKeys.swift b/Sources/NIOHTTPServer/LoggingKeys.swift new file mode 100644 index 0000000..a84755e --- /dev/null +++ b/Sources/NIOHTTPServer/LoggingKeys.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +enum LoggingKeys { + static var `protocol`: String { "protocol" } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+AbortRequest.swift b/Sources/NIOHTTPServer/NIOHTTPServer+AbortRequest.swift new file mode 100644 index 0000000..1d2dc8a --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+AbortRequest.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 + +#if HTTP3 +import HTTP3 +import NIOQUICHelpers +#endif + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + /// Aborts the exchange carrying a request on the wire, after that request's handler threw `error`. + /// + /// Which mechanism applies depends on the protocol serving the request: + /// - HTTP/1.1 has no stream to reset, so the response is abandoned and the connection is closed: a decision delegated to + /// ``HTTPKeepAliveHandler``, which already tracks how far the response has progressed and owns the + /// `Connection: close` handling. + /// - HTTP/2 and HTTP/3 reset the request's own stream, with the error codes `error` describes. + /// - HTTP/3 also asks the client to STOP_SENDING. + static func abortRequest(requestContext: RequestContext, error: any Error) { + let channel = requestContext.channel + + switch requestContext.connectionContext.httpVersion { + case .plaintextHTTP1_1, .http1_1: + var response = HTTPResponse(status: .internalServerError) + response.headerFields[.contentLength] = "0" + channel.triggerUserOutboundEvent( + HTTPKeepAliveHandler.RequestAborted(responseIfNotStarted: response), + promise: nil + ) + + case .http2: + // An error that does not describe its own code is reset with `INTERNAL_ERROR`. + let resetCode = + (error as? any HTTPServerHTTP2StreamResetErrorConvertible) + .map { HTTP2ErrorCode(networkCode: Int($0.http2StreamResetCode)) } ?? .internalError + + // `HTTP2FramePayloadToHTTPServerCodec` translates this event into a `RST_STREAM` frame. + channel.triggerUserOutboundEvent( + NIOHTTP2FramePayloadToHTTPEvent.reset(code: resetCode), + promise: nil + ) + + #if HTTP3 + case .http3: + let http3Error = error as? any HTTPServerHTTP3StreamResetErrorConvertible + let resetCode = Self.quicErrorCode(http3Error?.http3StreamResetCode) + let stopSendingCode = Self.quicErrorCode(http3Error?.http3StopSendingCode) + + // `RESET_STREAM` abandons the response direction and `STOP_SENDING` asks the client to + // stop sending the request body. + channel.triggerUserOutboundEvent(QUICResetStreamEvent(code: resetCode), promise: nil) + channel.triggerUserOutboundEvent(QUICStopSendingEvent(code: stopSendingCode), promise: nil) + #endif + } + } + + #if HTTP3 + /// Converts a raw HTTP/3 error code into a QUIC application error code. + /// + /// Substitutes `H3_INTERNAL_ERROR` when the error described no code, or described one that cannot be represented as + /// a QUIC variable-length integer. + private static func quicErrorCode(_ rawValue: UInt64?) -> QUICApplicationErrorCode { + // The force unwrap is safe: `H3_INTERNAL_ERROR` (0x0102) is always representable as a QUIC varint. + rawValue.flatMap(QUICApplicationErrorCode.init) + ?? QUICApplicationErrorCode(HTTP3ErrorCode.internalError.rawValue)! + } + #endif +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index cdc425a..bf9f105 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -33,13 +33,14 @@ extension NIOHTTPServer { public struct Connection: ~Copyable, Sendable { /// Per-protocol state. /// - /// - `http1_1` carries the request-channel's already-running inbound stream and outbound writer (the dispatcher - /// owns the channel and drives `executeThenClose`, so the writer is finished cleanly even if the connection - /// handler returns without calling ``handleRequests(handler:)``). + /// - `http1_1` carries the request channel plus its already-running inbound stream and outbound writer (the + /// dispatcher owns the channel and drives `executeThenClose`, so the writer is finished cleanly even if the + /// connection handler returns without calling ``handleRequests(handler:)``). /// - `http2` carries the connection channel and stream multiplexer. /// - `http3` carries an ``HTTP3ServerConnection``. enum HTTPProtocol: Sendable { case http1_1( + channel: any Channel, inbound: NIOAsyncChannelInboundStream, outbound: NIOAsyncChannelOutboundWriter ) @@ -74,6 +75,12 @@ extension NIOHTTPServer { /// Each request received on this connection is dispatched to `handler`. The /// loop returns when the peer closes the connection, the server shuts down, /// or an error occurs. + /// + /// A handler reports a failure by throwing. The error is not propagated out of this method: it aborts the + /// exchange carrying that request on the wire, closing the connection over HTTP/1.1, or resetting the stream + /// over HTTP/2 and HTTP/3. Conform the error to ``HTTPServerHTTP2StreamResetErrorConvertible`` and + /// ``HTTPServerHTTP3StreamResetErrorConvertible`` as appropriate to choose the protocol error codes; see + /// ``NIOHTTPServer/serve(handler:)`` for the full description. public consuming func handleRequests( handler: Handler ) async @@ -85,8 +92,9 @@ extension NIOHTTPServer { let server = self.server let context = self.context switch self.httpProtocol { - case .http1_1(let inbound, let outbound): + case .http1_1(let channel, let inbound, let outbound): await server.handleHTTP1RequestLoop( + channel: channel, inbound: inbound, outbound: outbound, handler: handler, @@ -111,6 +119,9 @@ extension NIOHTTPServer { /// Convenience overload accepting a closure instead of a /// ``HTTPServerRequestHandler`` conformance. /// + /// Throwing from the closure aborts the exchange carrying that request on the wire rather than propagating the + /// error; see ``handleRequests(handler:)``. + /// /// ```swift /// try await server.serve { connection, context in /// try await connection.handleRequests { request, requestContext, reader, responseSender in diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index 1758109..7d10cf6 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -19,7 +19,8 @@ public import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { /// The application-level HTTP version negotiated for a connection. - public enum HTTPVersion: String, Sendable, Hashable { + @nonexhaustive + public enum HTTPVersion: String, Sendable, Hashable, CaseIterable { case plaintextHTTP1_1 = "Plaintext HTTP/1.1" case http1_1 = "HTTP/1.1" case http2 = "HTTP/2" diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index da735d5..3592aa6 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -88,21 +88,25 @@ extension NIOHTTPServer { let connection = Connection( server: self, context: context, - httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + httpProtocol: .http1_1( + channel: requestChannel.channel, + inbound: inbound, + outbound: outbound + ) ) do { try await connectionHandler.handleConnection(connection: connection, context: context) } catch { self.logger.debug( "Error thrown by connection handler", - metadata: ["error": "\(error)"] + error: error ) } } } catch { self.logger.debug( "Error tearing down HTTP/1.1 channel", - metadata: ["error": "\(error)"] + error: error ) } } @@ -195,6 +199,7 @@ extension NIOHTTPServer { /// peer closes the connection, the task is cancelled, or an error /// occurs. func handleHTTP1RequestLoop( + channel: any Channel, inbound: NIOAsyncChannelInboundStream, outbound: NIOAsyncChannelOutboundWriter, handler: Handler, @@ -213,13 +218,15 @@ extension NIOHTTPServer { break requestLoop } + let requestContext = RequestContext(connectionContext: context, channel: channel) + guard - let recoveredIterator = try await self.invokeHandler( + let recoveredIterator = await self.invokeHandler( request: httpRequest, iterator: iterator, outbound: outbound, - handler: handler, - context: context + requestContext: requestContext, + handler: handler ) else { // Handler did not fully consume the request; cannot continue on this @@ -230,7 +237,10 @@ extension NIOHTTPServer { iterator = recoveredIterator } } catch { - self.logger.debug("Error thrown while handling HTTP/1.1 connection", metadata: ["error": "\(error)"]) + self.logger.debug( + "Error thrown while handling HTTP/1.1 connection", + error: error + ) } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index 558a7ec..09ae926 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -72,7 +72,10 @@ extension NIOHTTPServer { do { try await handler.handleConnection(connection: connection, context: context) } catch { - self.logger.debug("Error thrown by connection handler", metadata: ["error": "\(error)"]) + self.logger.debug( + "Error thrown by connection handler", + error: error + ) } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift index bb8eb70..835cd12 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+RequestContext.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// public import HTTPAPIs +import NIOCore public import X509 @available(anyAppleOS 26.0, *) @@ -28,8 +29,15 @@ extension NIOHTTPServer { public struct RequestContext: HTTPServerCapability.RequestContext, Sendable { let connectionContext: ConnectionContext - init(connectionContext: ConnectionContext) { + /// The channel carrying this request. + /// + /// For HTTP/1.1, which has no per-request stream, this is the connection channel; for HTTP/2 and HTTP/3 it is + /// the request's own stream channel. Used to abort the exchange when the request handler throws. + let channel: any Channel + + init(connectionContext: ConnectionContext, channel: any Channel) { self.connectionContext = connectionContext + self.channel = channel } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index eb88fd7..b8fabf4 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -86,7 +86,10 @@ extension NIOHTTPServer { do { negotiatedChannel = try await upgradeResult.get() } catch { - self.logger.debug("Negotiating ALPN failed", metadata: ["error": "\(error)"]) + self.logger.debug( + "Negotiating ALPN failed", + error: error + ) return } @@ -107,21 +110,25 @@ extension NIOHTTPServer { let connection = Connection( server: self, context: context, - httpProtocol: .http1_1(inbound: inbound, outbound: outbound) + httpProtocol: .http1_1( + channel: requestChannel.channel, + inbound: inbound, + outbound: outbound + ) ) do { try await connectionHandler.handleConnection(connection: connection, context: context) } catch { self.logger.debug( "Error thrown by connection handler", - metadata: ["error": "\(error)"] + error: error ) } } } catch { self.logger.debug( "Error handling HTTP/1.1 connection", - metadata: ["error": "\(error)"] + error: error ) } @@ -143,7 +150,7 @@ extension NIOHTTPServer { } catch { self.logger.debug( "Error thrown by connection handler", - metadata: ["error": "\(error)"] + error: error ) } } @@ -189,9 +196,9 @@ extension NIOHTTPServer { } } } catch { - self.logger.error( + self.logger.debug( "Error thrown while iterating over incoming HTTP/2 streams", - metadata: ["error": "\(error)"] + error: error ) } @@ -204,9 +211,9 @@ extension NIOHTTPServer { } catch ChannelError.alreadyClosed { () } catch { - self.logger.error( + self.logger.debug( "Error thrown while closing the HTTP/2 connection channel", - metadata: ["error": "\(error)"] + error: error ) } } @@ -379,19 +386,22 @@ extension NIOHTTPServer { return } - _ = try await self.invokeHandler( + // Built per request, as on HTTP/1.1. A stream carries exactly one request, so this runs once. + let requestContext = RequestContext(connectionContext: context, channel: channel.channel) + + _ = await self.invokeHandler( request: httpRequest, iterator: iterator, outbound: outbound, - handler: handler, - context: context + requestContext: requestContext, + handler: handler ) - // TODO: handle other state scenarios. - // For example, if we didn't finish reading but we wrote back a response, we - // should send a RST_STREAM with NO_ERROR set. If we finished reading but we - // didn't write back a response, then RST_STREAM is also likely appropriate but - // unclear about the error. + // TODO: handle the remaining state scenarios for a handler that returned without throwing. For + // example, if we didn't finish reading but we wrote back a response, we should send a RST_STREAM with + // NO_ERROR set. If we finished reading but we didn't write back a response, then RST_STREAM is also + // likely appropriate but unclear about the error. (A handler that throws already resets the stream; + // see `invokeHandler`.) // Finish the outbound and wait on the close future to make sure all pending // writes are actually written. @@ -401,7 +411,8 @@ extension NIOHTTPServer { } catch { self.logger.debug( "Error thrown while handling stream", - metadata: ["error": "\(error)", "protocol": "\(context.httpVersion)"] + error: error, + metadata: [LoggingKeys.protocol: "\(context.httpVersion)"] ) try? await channel.channel.close() } @@ -435,11 +446,9 @@ extension NIOHTTPServer { return .certificateVerified(.init(.init(nioSSLCerts))) case .failed(let error): - self.logger.error( + self.logger.debug( "Custom certificate verification failed", - metadata: [ - "failure-reason": .string(error.reason) - ] + error: error ) return .failed } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index d60b79a..99598b5 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -60,6 +60,9 @@ import X509 /// ) /// } /// ``` +/// +/// A request handler reports failure by throwing, which aborts that request's exchange on the wire rather than +/// propagating an error to the caller. See ``serve(handler:)`` and ``HTTPServerHTTP2StreamResetErrorConvertible``. @available(anyAppleOS 26.0, *) public struct NIOHTTPServer: HTTPServer { let logger: Logger @@ -105,6 +108,25 @@ public struct NIOHTTPServer: HTTPServer { /// - Parameter handler: A ``HTTPServerRequestHandler`` implementation that processes incoming HTTP /// requests. The handler receives each request along with a body reader and response sender function. /// + /// ## Failing a request + /// + /// A handler reports a failure by throwing from its `handle(request:requestContext:reader:responseSender:)` method. + /// The thrown error is never surfaced back to the caller of this method: it aborts the exchange that carries the request: + /// + /// - Over HTTP/1.1 there is no stream to reset, so the connection is closed. If the handler had not yet sent a + /// response head, the server sends `500 Internal Server Error` carrying `Connection: close` first; if a response + /// was already in flight it is abandoned, and the client observes a truncated response. + /// - Over HTTP/2, the stream is reset with a `RST_STREAM` frame. + /// - Over HTTP/3, the stream is reset with a QUIC `RESET_STREAM` frame, and a `STOP_SENDING` frame asks the client + /// to stop sending the request body. + /// + /// Conform the thrown error to ``HTTPServerHTTP2StreamResetErrorConvertible`` or ``HTTPServerHTTP3StreamResetErrorConvertible`` to + /// choose the protocol error codes that are sent. An error that describes neither resets the stream with the + /// internal error code of the protocol in use. + /// + /// Throwing after the response has been concluded aborts nothing: a complete response is never retracted, so the + /// only consequence is that the connection is not reused. + /// /// ## Example /// /// ```swift @@ -330,9 +352,9 @@ public struct NIOHTTPServer: HTTPServer { request: HTTPRequest, iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, outbound: NIOAsyncChannelOutboundWriter, - handler: Handler, - context: ConnectionContext - ) async throws -> NIOAsyncChannelInboundStream.AsyncIterator? + requestContext: RequestContext, + handler: Handler + ) async -> NIOAsyncChannelInboundStream.AsyncIterator? where Handler.RequestContext == RequestContext, Handler.Reader == Reader, @@ -354,19 +376,31 @@ public struct NIOHTTPServer: HTTPServer { do { try await handler.handle( request: request, - requestContext: RequestContext(connectionContext: context), + requestContext: requestContext, reader: requestReader, responseSender: responseSender ) } catch { - logger.error("Error thrown while handling request: \(error)") - if !readerState.wrapped.withLock({ $0.finishedReading }) { - logger.error("Did not finish reading but error thrown.") - } + // A throwing handler signals that the exchange failed. The error is deliberately not propagated to any + // caller: it exists to drive the wire, aborting the exchange with protocol error codes the error can + // choose by conforming to `HTTPServerHTTP2StreamResetErrorConvertible` / + // `HTTPServerHTTP3StreamResetErrorConvertible`. + self.logger.debug( + "Error thrown while handling request: aborting.", + error: error, + metadata: [LoggingKeys.protocol: "\(requestContext.connectionContext.httpVersion)"] + ) + + // Only abort a response that is still in flight. A response the handler already concluded has nothing left + // to abort, and resetting the stream afterwards can make the peer discard a response it has already + // received in full: RFC 9000 § 3.1 permits `RESET_STREAM` from the "Data Sent" state, so over HTTP/3 the + // reset does reach the client rather than being dropped as it is over HTTP/2. if !writerState.wrapped.withLock({ $0.finishedWriting }) { - logger.error("Did not write response but error thrown.") + Self.abortRequest(requestContext: requestContext, error: error) } - throw error + + // The handler failed, so this connection cannot carry another request. + return nil } // If the handler didn't properly conclude the response, the HTTP codec diff --git a/Sources/StreamResetExample/StreamResetExample.swift b/Sources/StreamResetExample/StreamResetExample.swift new file mode 100644 index 0000000..46232fb --- /dev/null +++ b/Sources/StreamResetExample/StreamResetExample.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import BasicContainers +import Crypto +import ExampleSupport +import Foundation +import Logging +import NIOHTTP2 +import NIOHTTPServer +import X509 + +#if HTTP3 +import HTTP3 +#endif + +/// A failure that stands in for a proxy being unable to establish its upstream tunnel. +/// +/// Conforming to the stream-reset protocols chooses the error codes the server puts on the wire when this error is +/// thrown. The protocols take the plain numeric values from the HTTP specifications so that they carry no dependency of +/// their own; a conformance is free to derive those values from whichever code types it already has, as this one does +/// from NIO's. +struct TunnelFailure: Error { + let reason: String +} + +extension TunnelFailure: HTTPServerHTTP2StreamResetErrorConvertible { + /// `CONNECT_ERROR` (RFC 9113 § 7): the TCP connection behind a `CONNECT` request failed. + var http2StreamResetCode: UInt32 { UInt32(HTTP2ErrorCode.connectError.networkCode) } +} + +#if HTTP3 +extension TunnelFailure: HTTPServerHTTP3StreamResetErrorConvertible { + /// `H3_CONNECT_ERROR` (RFC 9114 § 8.1), the HTTP/3 counterpart of `CONNECT_ERROR`. + var http3StreamResetCode: UInt64 { HTTP3ErrorCode.connectError.rawValue } + + /// The server is no longer reading the request body, so ask the client to stop sending it. + var http3StopSendingCode: UInt64 { HTTP3ErrorCode.connectError.rawValue } +} +#endif + +/// A failure with no stream-reset conformance, to show the fallback. +struct UnexpectedFailure: Error {} + +@main +@available(anyAppleOS 26.0, *) +struct StreamResetExample { + static func main() async throws { + try await serve() + } + + @concurrent + static func serve() async throws { + var rootLogger = Logger(label: "StreamResetExample") + rootLogger.logLevel = .trace + + try await withLogger(rootLogger) { rootLogger in + let server = NIOHTTPServer( + logger: rootLogger, + configuration: try .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], + transportSecurity: .tls(credentials: try .selfSigned()) + ) + ) + + try await server.serve { request, requestContext, reader, responseSender in + switch request.path { + case "/tunnel": + throw TunnelFailure(reason: "upstream refused the connection") + + case "/boom": + throw UnexpectedFailure() + + default: + var body = UniqueArray(copying: "Try /tunnel or /boom".utf8) + try await responseSender.sendAndFinish( + HTTPResponse(status: .ok, headerFields: [.contentType: "text/plain"]), + buffer: &body + ) + } + } + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials { + /// A throwaway self-signed certificate, so the example needs no files on disk. + fileprivate static func selfSigned() throws -> Self { + let privateKey = P256.Signing.PrivateKey() + let certificate = try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + + return .x509( + .certificates(chain: [certificate], privateKey: Certificate.PrivateKey(privateKey)) + ) + } +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerStreamResetTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerStreamResetTests.swift new file mode 100644 index 0000000..0b65a37 --- /dev/null +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerStreamResetTests.swift @@ -0,0 +1,455 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIOCore +import NIOEmbedded +import NIOHPACK +import NIOHTTP2 +import NIOHTTPTypes +import Testing + +@testable import NIOHTTPServer + +#if HTTP3 +import HTTP3 +import NIOQUICHelpers +#endif + +/// An error that maps to protocol-specific codes, as a request handler author would write. +enum StreamResetTestError: Error { + case connectFailed +} + +extension StreamResetTestError: HTTPServerHTTP2StreamResetErrorConvertible { + // The protocol takes the raw on-the-wire value, so derive it from NIO's code type. + var http2StreamResetCode: UInt32 { UInt32(HTTP2ErrorCode.connectError.networkCode) } +} + +#if HTTP3 +extension StreamResetTestError: HTTPServerHTTP3StreamResetErrorConvertible { + var http3StreamResetCode: UInt64 { HTTP3ErrorCode.connectError.rawValue } + var http3StopSendingCode: UInt64 { HTTP3ErrorCode.connectError.rawValue } +} +#endif + +@Suite +struct NIOHTTPServerStreamResetTests { + let clientLogger = Logger(label: "NIOHTTPServerStreamResetTests.client") + let serverLogger = Logger(label: "NIOHTTPServerStreamResetTests.server") + + // MARK: - HTTP/1.1 + + @Test("Aborting while the response head is still buffered flushes it with Connection: close and no response end") + @available(anyAppleOS 26.0, *) + func testHTTP1AbortWhileResponseHeadIsBuffered() throws { + let channel = EmbeddedChannel() + try channel.connect(to: try .init(ipAddress: "127.0.0.1", port: 0)).wait() + try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) + + // The request head arrives but not its `.end`, so a response head gets buffered rather than streamed. + try channel.writeInbound( + HTTPRequestPart.head(.init(method: .post, scheme: "http", authority: "test", path: "/")) + ) + + // Write (without flushing) the handler's own response head so it lands in the keep-alive handler's buffer. + _ = channel.write(HTTPResponsePart.head(.init(status: .ok))) + + NIOHTTPServer.abortRequest( + requestContext: .init(connectionContext: .init(httpVersion: .plaintextHTTP1_1), channel: channel), + error: TestError.intentional + ) + channel.embeddedEventLoop.run() + + // The buffered head still reaches the wire, amended with `Connection: close`. + switch try channel.readOutbound(as: HTTPResponsePart.self) { + case .head(let response): + #expect(response.status == .ok) + #expect( + response.headerFields[.connection] == "close", + "Expected Connection: close, got headers: \(response.headerFields)" + ) + + case let other: + Issue.record("Expected the buffered response head, got \(String(describing: other))") + } + + // Crucially, no response `.end` is synthesized. Under chunked encoding `.end` writes the terminating chunk, + // which would tell the client the abandoned response was complete. Omitting it truncates the response, which is + // the signal we want. + if let unexpected = try channel.readOutbound(as: HTTPResponsePart.self) { + Issue.record("Expected no further response parts, got \(unexpected)") + } + + _ = try? channel.finish() + } + + @Test("Aborting after the response head reached the wire sends no second head and no response end") + @available(anyAppleOS 26.0, *) + func testHTTP1AbortWhileStreaming() throws { + let channel = EmbeddedChannel() + try channel.connect(to: try .init(ipAddress: "127.0.0.1", port: 0)).wait() + try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) + + // A fully received request means the response streams directly instead of being buffered. + try channel.writeInbound( + HTTPRequestPart.head(.init(method: .get, scheme: "http", authority: "test", path: "/")) + ) + try channel.writeInbound(HTTPRequestPart.end(nil)) + try channel.writeOutbound(HTTPResponsePart.head(.init(status: .ok))) + + switch try channel.readOutbound(as: HTTPResponsePart.self) { + case .head: + () + + case let other: + Issue.record("Expected the response head, got \(String(describing: other))") + } + + NIOHTTPServer.abortRequest( + requestContext: .init(connectionContext: .init(httpVersion: .plaintextHTTP1_1), channel: channel), + error: TestError.intentional + ) + channel.embeddedEventLoop.run() + + // The head is already on the wire and cannot be recalled, so nothing further is written: no second head + // (which would corrupt the framing) and no fabricated `.end` (which would claim the abandoned response was + // complete). The client is left observing a truncated response. + if let unexpected = try channel.readOutbound(as: HTTPResponsePart.self) { + Issue.record("Expected no further response parts, got \(unexpected)") + } + + _ = try? channel.finish() + } + + @Test( + "HTTP/1.1: throwing before the response head sends 500 with Connection: close", + arguments: [NIOHTTPServer.HTTPVersion.plaintextHTTP1_1, .http1_1] + ) + @available(anyAppleOS 26.0, *) + func testHTTP1ThrowingBeforeResponseHeadSendsInternalServerError( + http1Variant: NIOHTTPServer.HTTPVersion + ) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: http1Variant, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in + throw StreamResetTestError.connectFailed + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .get, for: http1Variant)) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [ + .init( + status: .internalServerError, + headerFields: [.contentLength: "0", .connection: "close"] + ) + ], + expectedBody: [], + expectStreamEnd: true + ) + } + } + + // MARK: - HTTP/2 + + @Test("HTTP/2: throwing a conforming error resets the stream with that error's code") + @available(anyAppleOS 26.0, *) + func testHTTP2ThrowingConformingErrorResetsStream() async throws { + try await self.assertHTTP2Reset( + throwing: StreamResetTestError.connectFailed, + expectedCode: .connectError + ) + } + + @Test("HTTP/2: throwing a non-conforming error resets the stream with INTERNAL_ERROR") + @available(anyAppleOS 26.0, *) + func testHTTP2ThrowingNonConformingErrorResetsStreamWithInternalError() async throws { + try await self.assertHTTP2Reset( + throwing: TestError.intentional, + expectedCode: .internalError + ) + } + + /// Runs a handler that throws `error` over HTTP/2 and asserts the client observes `RST_STREAM(expectedCode)`. + @available(anyAppleOS 26.0, *) + private func assertHTTP2Reset( + throwing error: any Error, + expectedCode: HTTP2ErrorCode, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: .http2, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + try await TestHelpers.withClientServerConnection( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in + throw error + } + ) { _, clientConnection in + let rawStream = try await clientConnection.makeRawHTTP2RequestStream() + try await rawStream.executeThenClose { inbound, outbound in + let requestHeaders: HPACKHeaders = [ + ":method": "GET", + ":scheme": "https", + ":authority": "test", + ":path": "/", + ] + try await outbound.write(.headers(.init(headers: requestHeaders, endStream: true))) + + var observedCode: HTTP2ErrorCode? + for try await payload in inbound { + if case .rstStream(let code) = payload { + observedCode = code + break + } + } + + #expect( + observedCode == expectedCode, + "Expected RST_STREAM(\(expectedCode)), got \(String(describing: observedCode)).", + sourceLocation: sourceLocation + ) + } + } + } + + @Test("HTTP/2: throwing after the response head still resets the stream") + @available(anyAppleOS 26.0, *) + func testHTTP2ThrowingAfterResponseHeadResetsStream() async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: .http2, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + try await TestHelpers.withClientServerConnection( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, sender in + // Establish the response, as a CONNECT tunnel would, then fail before concluding it. + _ = try await sender.send(.init(status: .ok)) + throw StreamResetTestError.connectFailed + } + ) { _, clientConnection in + let rawStream = try await clientConnection.makeRawHTTP2RequestStream() + try await rawStream.executeThenClose { inbound, outbound in + let requestHeaders: HPACKHeaders = [ + ":method": "GET", + ":scheme": "https", + ":authority": "test", + ":path": "/", + ] + try await outbound.write(.headers(.init(headers: requestHeaders, endStream: true))) + + var sawResponseHeaders = false + var resetCode: HTTP2ErrorCode? + for try await payload in inbound { + switch payload { + case .headers: + sawResponseHeaders = true + case .rstStream(let code): + resetCode = code + default: + () + } + + // Stop at the reset: continuing to iterate surfaces the stream's terminal + // `NIOHTTP2Errors.StreamClosed`, which carries the same code. + if resetCode != nil { break } + } + + #expect(sawResponseHeaders, "Expected the response head the handler sent.") + #expect( + resetCode == .connectError, + "Expected RST_STREAM(connectError), got \(String(describing: resetCode))." + ) + } + } + } + + #if HTTP3 + // MARK: - HTTP/3 + + @Test("Aborting an HTTP/3 stream emits RESET_STREAM and STOP_SENDING carrying the resolved codes") + @available(anyAppleOS 26.0, *) + func testHTTP3AbortEmitsResetStreamAndStopSending() throws { + let channel = EmbeddedChannel() + try channel.connect(to: try .init(ipAddress: "127.0.0.1", port: 0)).wait() + let recorder = OutboundUserEventRecorder() + try channel.pipeline.syncOperations.addHandler(recorder) + + NIOHTTPServer.abortRequest( + requestContext: .init(connectionContext: .init(httpVersion: .http3), channel: channel), + error: StreamResetTestError.connectFailed + ) + channel.embeddedEventLoop.run() + + let resetEvents = recorder.events.compactMap { $0 as? QUICResetStreamEvent } + let stopSendingEvents = recorder.events.compactMap { $0 as? QUICStopSendingEvent } + + #expect(resetEvents.count == 1, "Expected exactly one RESET_STREAM.") + #expect(stopSendingEvents.count == 1, "Expected exactly one STOP_SENDING.") + + let expectedCode = QUICApplicationErrorCode(0x010f) + #expect(resetEvents.first?.code == expectedCode) + #expect(stopSendingEvents.first?.code == expectedCode) + + _ = try? channel.finish() + } + + @Test("Aborting an HTTP/3 stream with an error describing no codes uses H3_INTERNAL_ERROR") + @available(anyAppleOS 26.0, *) + func testHTTP3AbortWithNonConformingErrorUsesInternalError() throws { + let channel = EmbeddedChannel() + try channel.connect(to: try .init(ipAddress: "127.0.0.1", port: 0)).wait() + let recorder = OutboundUserEventRecorder() + try channel.pipeline.syncOperations.addHandler(recorder) + + NIOHTTPServer.abortRequest( + requestContext: .init(connectionContext: .init(httpVersion: .http3), channel: channel), + error: TestError.intentional + ) + channel.embeddedEventLoop.run() + + let expectedCode = QUICApplicationErrorCode(HTTP3ErrorCode.internalError.rawValue) + #expect(recorder.events.compactMap { $0 as? QUICResetStreamEvent }.first?.code == expectedCode) + #expect(recorder.events.compactMap { $0 as? QUICStopSendingEvent }.first?.code == expectedCode) + + _ = try? channel.finish() + } + + @Test("HTTP/3: throwing from the handler resets the stream, failing the client's read") + @available(anyAppleOS 26.0, *) + func testHTTP3ThrowingResetsStream() async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: .http3, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + // The stream is reset rather than completed, so draining the response fails instead of ending cleanly. + let httpError = try await #require(throws: HTTP3Error.self) { + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, _ in + throw StreamResetTestError.connectFailed + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .get, for: .http3)) + try await outbound.write(.end(nil)) + + for try await _ in inbound {} + } + } + + #expect(httpError.code == .remoteStreamError) + #expect(httpError.h3ErrorCode == .connectError) + } + @Test("HTTP/3: throwing after the response head still resets the stream") + @available(anyAppleOS 26.0, *) + func testHTTP3ThrowingAfterResponseHeadResetsStream() async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: .http3, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + // The response is abandoned after its head, so draining it fails instead of ending cleanly. + let httpError = try await #require(throws: HTTP3Error.self) { + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, sender in + // Establish the response, as a CONNECT tunnel would, then fail before concluding it. + _ = try await sender.send(.init(status: .ok)) + throw StreamResetTestError.connectFailed + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .get, for: .http3)) + try await outbound.write(.end(nil)) + + for try await _ in inbound {} + } + } + + #expect(httpError.code == .remoteStreamError) + #expect(httpError.h3ErrorCode == .connectError) + } + + #endif + // MARK: - All versions + + @Test( + "Throwing after the response is concluded still delivers the full response", + arguments: NIOHTTPServer.HTTPVersion.allCases + ) + @available(anyAppleOS 26.0, *) + func testThrowingAfterConcludedResponseDeliversFullResponse( + httpVersion: NIOHTTPServer.HTTPVersion + ) async throws { + let (server, clientConfiguration) = try TestHelpers.makeServerAndClientConfiguration( + for: httpVersion, + clientLogger: self.clientLogger, + serverLogger: self.serverLogger + ) + + try await TestHelpers.withClientServerRequestChannel( + clientConfiguration: clientConfiguration, + server: server, + serverHandler: HTTPServerClosureRequestHandler { _, _, _, sender in + // Conclude the response fully, then fail. + try await sender.sendAndFinish(.init(status: .ok, headerFields: [.contentLength: "0"])) + throw StreamResetTestError.connectFailed + } + ) { _, inbound, outbound in + try await outbound.write(.testHead(method: .get, for: httpVersion)) + try await outbound.write(.end(nil)) + + try await TestHelpers.validateResponse( + inbound, + expectedHead: [.init(status: .ok, headerFields: [.contentLength: "0"])], + expectedBody: [], + expectStreamEnd: true + ) + } + } +} + +/// Records user outbound events (QUIC `RESET_STREAM` / `STOP_SENDING`) passing through it, so tests can assert exactly +/// which control events an abort emits. +final class OutboundUserEventRecorder: ChannelOutboundHandler, @unchecked Sendable { + typealias OutboundIn = Any + + private(set) var events: [Any] = [] + + func triggerUserOutboundEvent(context: ChannelHandlerContext, event: Any, promise: EventLoopPromise?) { + self.events.append(event) + context.triggerUserOutboundEvent(event, promise: promise) + } +} diff --git a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift index 23cb526..9b97e89 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestClientConnection.swift @@ -82,6 +82,18 @@ struct TestClientConnection { } } + /// Asserts the connection is HTTP/2, then opens a stream exposing raw `HTTP2Frame.FramePayload`s so a test can + /// observe frames the HTTP client codec would drop (such as `RST_STREAM`). + func makeRawHTTP2RequestStream( + sourceLocation: SourceLocation = #_sourceLocation + ) async throws -> NIOAsyncChannel { + guard case .http2(_, let streamMultiplexer) = self.connectionProtocol else { + Issue.record("Expected an HTTP/2 connection.", sourceLocation: sourceLocation) + throw TestError.invalidClientConfiguration + } + return try await streamMultiplexer.makeRawRequestStream() + } + /// Closes the underlying connection. func close() async throws { switch self.connectionProtocol { @@ -234,4 +246,21 @@ extension NIOHTTP2Handler.AsyncStreamMultiplexer { } } } + + /// Opens a stream without the `HTTP2FramePayloadToHTTPClientCodec`, exposing raw `HTTP2Frame.FramePayload`s. + /// + /// Unlike ``makeRequestStream()``, this lets a test observe frames the codec would otherwise drop, such as + /// `RST_STREAM`. + func makeRawRequestStream() async throws -> NIOAsyncChannel< + HTTP2Frame.FramePayload, HTTP2Frame.FramePayload + > { + try await self.openStream { channel in + channel.eventLoop.makeCompletedFuture { + try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } + } }