diff --git a/Package.swift b/Package.swift index 7bc38bd..4c41e16 100644 --- a/Package.swift +++ b/Package.swift @@ -103,7 +103,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.101.3"), .package(url: "https://github.com/apple/swift-nio-quic.git", .upToNextMinor(from: "0.2.1")), .package(url: "https://github.com/apple/swift-nio-quic-helpers.git", .upToNextMinor(from: "0.1.0")), - .package(url: "https://github.com/apple/swift-nio-http3.git", .upToNextMinor(from: "0.2.0")), + // TODO: Update once datagram APIs are released. + .package(url: "https://github.com/apple/swift-nio-http3.git", branch: "main"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.37.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.34.1"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.44.0"), diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+ConnectionSettings.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+ConnectionSettings.swift index 737092b..cd1b3b4 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+ConnectionSettings.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+ConnectionSettings.swift @@ -37,12 +37,31 @@ extension NIOHTTPServerConfiguration.HTTP3 { /// `SETTINGS_MAX_FIELD_SECTION_SIZE`. public var maximumFieldSectionSize: UInt64? + /// Whether the server should advertise support for receiving HTTP/3 datagrams. + /// + /// - SeeAlso: https://www.rfc-editor.org/rfc/rfc9297.html#section-2.1.1-1. Corresponds to + /// `SETTINGS_H3_DATAGRAM`. + var http3Datagram: Bool + + init( + qpackMaximumTableCapacity: UInt64, + qpackBlockedStreams: UInt64, + maximumFieldSectionSize: UInt64? + ) { + self.qpackMaximumTableCapacity = qpackMaximumTableCapacity + self.qpackBlockedStreams = qpackBlockedStreams + self.maximumFieldSectionSize = maximumFieldSectionSize + // Set `http3Datagram` to `true`. This will later be updated in the `NIOHTTPServerConfiguration.HTTP3` + // callsite to stay consistent with the QUIC configuration. + self.http3Datagram = true + } + /// The default HTTP/3 connection settings configuration. /// /// Uses the following default values: - /// - `qpackMaximumTableCapacity`: 0. - /// - `qpackBlockedStreams`: 0. - /// - `maximumFieldSectionSize`: `nil` (no field section size limit). + /// - `qpackMaximumTableCapacity`: 0 + /// - `qpackBlockedStreams`: 0 + /// - `maximumFieldSectionSize`: `nil` (no field section size limit) public static var defaults: Self { Self( qpackMaximumTableCapacity: 0, @@ -59,7 +78,8 @@ extension HTTP3.HTTP3Settings { self.init( qpackMaximumTableCapacity: configuration.qpackMaximumTableCapacity, qpackBlockedStreams: configuration.qpackBlockedStreams, - maximumFieldSectionSize: configuration.maximumFieldSectionSize + maximumFieldSectionSize: configuration.maximumFieldSectionSize, + h3Datagram: configuration.http3Datagram ) } } diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+DatagramConfiguration.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+DatagramConfiguration.swift new file mode 100644 index 0000000..aa50dfb --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+DatagramConfiguration.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// 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 && UnstableHTTPDatagrams +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3 { + public struct DatagramConfiguration: Sendable, Hashable { + /// The maximum datagram frame size in bytes. + public var maxDatagramFrameSize: Int { + didSet { + self.validateMaxDatagramFrameSize() + } + } + + private func validateMaxDatagramFrameSize() { + precondition( + self.maxDatagramFrameSize != 0, + "When maxDatagramFrameSize == 0, support for receiving HTTP/3 datagrams is disabled. Set `datagramConfiguration` to `nil` if you do not want to receive datagrams." + ) + } + + /// The maximum number of inbound HTTP/3 datagrams that will be buffered for each stream. + public var maxBufferedDatagrams: Int + + init(maxDatagramFrameSize: Int, maxBufferedDatagrams: Int) { + self.maxDatagramFrameSize = maxDatagramFrameSize + self.maxBufferedDatagrams = maxBufferedDatagrams + + self.validateMaxDatagramFrameSize() + } + + /// The default HTTP/3 datagram configuration. Uses the following default values: + /// + /// - `maxDatagramFrameSize`: 65535 + /// - `maxBufferedDatagrams`: 16 + public static var defaults: Self { + Self(maxDatagramFrameSize: 65535, maxBufferedDatagrams: 16) + } + } +} +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift index 3d9f931..5877e37 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift @@ -159,6 +159,43 @@ extension NIOHTTPServerConfiguration.HTTP3 { /// debugging and analysis. public var qLogConfiguration: QLogConfiguration? + /// The maximum datagram frame size in bytes. If set to 0, the server will not advertise support for receiving + /// unreliable datagrams. + var maxDatagramFrameSize: Int + + init( + serverName: String, + keyExchangeGroup: KeyExchangeGroup, + maxIdleTimeout: Duration, + initialMaxData: Int, + initialMaxStreamDataBidirectionalLocal: Int, + initialMaxStreamDataBidirectionalRemote: Int, + initialMaxStreamDataUnidirectional: Int, + initialMaxStreamsBidirectional: Int, + initialMaxStreamsUnidirectional: Int, + keepAliveInterval: Duration? = nil, + sendRetry: Bool, + keyLogPath: String? = nil, + qLogConfiguration: QLogConfiguration? = nil + ) { + self.serverName = serverName + self.keyExchangeGroup = keyExchangeGroup + self.maxIdleTimeout = maxIdleTimeout + self.initialMaxData = initialMaxData + self.initialMaxStreamDataBidirectionalLocal = initialMaxStreamDataBidirectionalLocal + self.initialMaxStreamDataBidirectionalRemote = initialMaxStreamDataBidirectionalRemote + self.initialMaxStreamDataUnidirectional = initialMaxStreamDataUnidirectional + self.initialMaxStreamsBidirectional = initialMaxStreamsBidirectional + self.initialMaxStreamsUnidirectional = initialMaxStreamsUnidirectional + self.keepAliveInterval = keepAliveInterval + self.sendRetry = sendRetry + self.keyLogPath = keyLogPath + self.qLogConfiguration = qLogConfiguration + // Set `maxDatagramFrameSize` to 65535. This will later be updated in the + // `NIOHTTPServerConfiguration.HTTP3` callsite to stay consistent with the HTTP/3 configuration. + self.maxDatagramFrameSize = 65535 + } + /// The default QUIC transport configuration. /// /// Uses the following default values: @@ -275,7 +312,8 @@ extension NIOQUIC.QUICConfiguration { keepAliveInterval: config.keepAliveInterval, sendRetry: config.sendRetry, keyLogPath: config.keyLogPath, - qLogConfiguration: config.qLogConfiguration.map { .init($0) } + qLogConfiguration: config.qLogConfiguration.map { .init($0) }, + maxDatagramFrameSize: config.maxDatagramFrameSize ) } } diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/NIOHTTPServerConfiguration+HTTP3.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/NIOHTTPServerConfiguration+HTTP3.swift index 0f5fb08..141fadb 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/NIOHTTPServerConfiguration+HTTP3.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/NIOHTTPServerConfiguration+HTTP3.swift @@ -32,21 +32,66 @@ extension NIOHTTPServerConfiguration { /// HTTP/3 connection settings exchanged with the client during connection establishment. public var connectionSettings: ConnectionSettings = .defaults + #if UnstableHTTPDatagrams + /// The HTTP/3 datagram configuration. If set to `nil`, the server will not advertise support for receiving + /// HTTP/3 datagrams. + public var datagramConfiguration: DatagramConfiguration? = .defaults { + didSet { + self.updateDatagramConfiguration() + } + } + #endif // UnstableHTTPDatagrams + + private mutating func updateDatagramConfiguration() { + // Update `self.quicConfiguration` and `self.connectionSettings` when the value changes. + if let datagramConfig = self.datagramConfiguration { + self.quicConfiguration.maxDatagramFrameSize = datagramConfig.maxDatagramFrameSize + self.connectionSettings.http3Datagram = true + } else { + // Set `maxDatagramFrameSize` to 0 and `http3Datagram` to `false` so that the server doesn't + // advertise support for receiving datagrams. + self.quicConfiguration.maxDatagramFrameSize = 0 + self.connectionSettings.http3Datagram = false + } + } + + #if UnstableHTTPDatagrams /// Creates an HTTP/3 configuration. /// /// - Parameters: /// - preferHuffmanEncoding: Whether Huffman encoding is used where applicable. /// - quicConfiguration: QUIC transport parameters. /// - connectionSettings: HTTP/3 connection-level settings exchanged with the client. + /// - datagramConfiguration: The HTTP/3 datagram configuration. If set to `nil`, the server will not advertise + /// support for receiving HTTP/3 datagrams. public init( preferHuffmanEncoding: Bool, quicConfiguration: QUICConfiguration, connectionSettings: ConnectionSettings, + datagramConfiguration: DatagramConfiguration? = .defaults ) { self.preferHuffmanEncoding = preferHuffmanEncoding self.quicConfiguration = quicConfiguration self.connectionSettings = connectionSettings + self.datagramConfiguration = datagramConfiguration } + #else + /// Creates an HTTP/3 configuration. + /// + /// - Parameters: + /// - preferHuffmanEncoding: Whether Huffman encoding is used where applicable. + /// - quicConfiguration: QUIC transport parameters. + /// - connectionSettings: HTTP/3 connection-level settings exchanged with the client. + public init( + preferHuffmanEncoding: Bool, + quicConfiguration: QUICConfiguration, + connectionSettings: ConnectionSettings, + ) { + self.preferHuffmanEncoding = preferHuffmanEncoding + self.quicConfiguration = quicConfiguration + self.connectionSettings = connectionSettings + } + #endif // UnstableHTTPDatagrams /// The default HTTP/3 configuration. /// @@ -54,12 +99,22 @@ extension NIOHTTPServerConfiguration { /// - `preferHuffmanEncoding`: `true`. /// - `quicConfiguration`: ``QUICConfiguration/defaults``. /// - `connectionSettings`: ``ConnectionSettings/defaults``. + /// - `datagramConfiguration`: ``DatagramConfiguration/defaults``. public static var defaults: Self { + #if UnstableHTTPDatagrams Self( preferHuffmanEncoding: true, quicConfiguration: .defaults, connectionSettings: .defaults, + datagramConfiguration: .defaults + ) + #else + Self( + preferHuffmanEncoding: true, + quicConfiguration: .defaults, + connectionSettings: .defaults ) + #endif // UnstableHTTPDatagrams } // The fallback connection RTT to use if there is an error obtaining the RTT estimate channel option. diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift index bae806d..a07eece 100644 --- a/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfiguration/HTTP3+SwiftConfiguration.swift @@ -20,24 +20,36 @@ extension NIOHTTPServerConfiguration.HTTP3 { /// Initialize an HTTP/3 configuration from a config reader. /// /// ## Configuration keys: - /// HTTP/3 configuration contains three sub-scopes. All keys are optional and resolve to their default values if not + /// HTTP/3 configuration contains four sub-scopes. All keys are optional and resolve to their default values if not /// provided: /// - ``NIOHTTPServerConfiguration/HTTP3/defaults`` /// - ``NIOHTTPServerConfiguration/HTTP3/ConnectionSettings/defaults`` /// - ``NIOHTTPServerConfiguration/HTTP3/QUICConfiguration/defaults``. + /// - ``NIOHTTPServerConfiguration/HTTP3/DatagramConfiguration/defaults``. /// /// - **`"protocolConfiguration"`**: HTTP/3 protocol-level settings (see ``ProtocolConfiguration/init(config:)``). /// - **`"connectionSettings"`**: HTTP/3 connection settings exchanged with the client (see /// ``ConnectionSettings/init(config:)``). /// - **`"quicConfiguration"`**: QUIC transport configuration (see ``QUICConfiguration/init(config:)``). + /// - **`"datagramConfiguration"`**: HTTP/3 datagram configuration (see ``DatagramConfiguration/init(config:)``). + /// Note that the `UnstableHTTPDatagrams` trait must be enabled for this configuration to have any effect. /// /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) throws { + #if UnstableHTTPDatagrams + self.init( + preferHuffmanEncoding: config.bool(forKey: "preferHuffmanEncoding", default: true), + quicConfiguration: try .init(config: config.scoped(to: "quicConfiguration")), + connectionSettings: .init(config: config.scoped(to: "connectionSettings")), + datagramConfiguration: .init(config: config.scoped(to: "datagramConfiguration")) + ) + #else self.init( preferHuffmanEncoding: config.bool(forKey: "preferHuffmanEncoding", default: true), quicConfiguration: try .init(config: config.scoped(to: "quicConfiguration")), connectionSettings: .init(config: config.scoped(to: "connectionSettings")) ) + #endif } } @@ -198,4 +210,31 @@ extension NIOHTTPServerConfiguration.HTTP3.ConnectionSettings { ) } } + +#if UnstableHTTPDatagrams +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3.DatagramConfiguration { + /// Initialize HTTP/3 connection settings from a config reader. + /// + /// ## Configuration keys: + /// - `datagramsEnabled` (bool, optional, default: true): Whether the server should advertise support for receiving + /// HTTP/3 datagrams. + /// - `maxDatagramFrameSize` (int, optional, default: 65535): The maximum datagram frame size in bytes. + /// - `maxBufferedDatagrams` (int, optional, default: 16): The maximum number of inbound HTTP/3 datagrams that will + /// be buffered for each stream. + /// + /// - SeeAlso: ``NIOHTTPServerConfiguration/HTTP3/DatagramConfiguration``. + /// + /// - Parameter config: The configuration reader. + public init?(config: ConfigSnapshotReader) { + guard config.bool(forKey: "datagramsEnabled", default: true) else { return nil } + + self.init( + maxDatagramFrameSize: config.int(forKey: "maxDatagramFrameSize", default: 65535), + maxBufferedDatagrams: config.int(forKey: "maxBufferedDatagrams", default: 16) + ) + } +} +#endif // UnstableHTTPDatagrams + #endif // HTTP3 && Configuration diff --git a/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramDemultiplexer.swift b/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramDemultiplexer.swift new file mode 100644 index 0000000..9afda62 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramDemultiplexer.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// 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 && UnstableHTTPDatagrams + +import NIOCore +import NIOHTTP3 +import NIOQUICHelpers + +/// Routes inbound HTTP/3 datagrams to registered ``HTTP3DatagramStream`` instances. +@available(anyAppleOS 26.0, *) +final class HTTP3DatagramDemultiplexer: ChannelInboundHandler { + typealias InboundIn = HTTP3Datagram + + /// The ``HTTP3DatagramStream`` instance for each open request stream. + private var datagramStreams: [QUICStreamID: HTTP3UnreliableDatagramStream] = [:] + + /// Starts routing datagrams received for `datagramStream.streamID` to the provided `datagramStream`. + /// + /// - Precondition: Must only be called on the connection channel's event loop. + func register(datagramStream: HTTP3UnreliableDatagramStream) { + self.datagramStreams[datagramStream.streamID] = datagramStream + } + + /// Stops routing datagrams to `streamID`. + /// + /// - Precondition: Must only be called on the connection channel's event loop. + func deregister(streamID: QUICStreamID) { + self.datagramStreams.removeValue(forKey: streamID) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let datagram = self.unwrapInboundIn(data) + self.datagramStreams[datagram.streamID]?.receive(datagram.payload) + } + + func handlerRemoved(context: ChannelHandlerContext) { + for datagramStream in self.datagramStreams.values { + datagramStream.finish() + } + self.datagramStreams.removeAll() + } +} + +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramHandler.swift b/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramHandler.swift new file mode 100644 index 0000000..70530ab --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/HTTP3DatagramHandler.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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 && UnstableHTTPDatagrams + +import NIOCore +import NIOHTTP3 + +/// A stub for the HTTP/3 datagram handler which `swift-nio-http3` will provide. +@available(anyAppleOS 26.0, *) +final class HTTP3DatagramHandler: ChannelDuplexHandler { + typealias InboundIn = HTTP3Datagram + typealias InboundOut = HTTP3Datagram + + typealias OutboundIn = HTTP3Datagram + typealias OutboundOut = HTTP3Datagram +} + +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/Datagrams/HTTP3UnreliableDatagramStream.swift b/Sources/NIOHTTPServer/Datagrams/HTTP3UnreliableDatagramStream.swift new file mode 100644 index 0000000..3da1836 --- /dev/null +++ b/Sources/NIOHTTPServer/Datagrams/HTTP3UnreliableDatagramStream.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// 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 && UnstableHTTPDatagrams + +import NIOCore +import NIOHTTP3 +import NIOQUICHelpers + +@available(anyAppleOS 26.0, *) +struct HTTP3UnreliableDatagramStream: Sendable { + /// The maximum number of received but not yet consumed datagrams to buffer. + /// + /// Note: The size of a datagram is at most the negotiated `max_datagram_frame_size`. Therefore, the memory occupied + /// by buffered datagrams is bounded by the negotiated `max_datagram_frame_size` multiplied by this count. + var maxBufferedDatagrams: Int + + // The QUIC stream ID. + let streamID: QUICStreamID + + /// The inbound datagram stream. + let inbound: AsyncStream + + private let inboundContinuation: AsyncStream.Continuation + + /// The connection channel. + private let connectionChannel: any Channel + + init(streamID: QUICStreamID, connectionChannel: any Channel, maxBufferedDatagrams: Int) { + self.streamID = streamID + self.connectionChannel = connectionChannel + self.maxBufferedDatagrams = maxBufferedDatagrams + + (self.inbound, self.inboundContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(maxBufferedDatagrams) + ) + } + + /// Delivers an inbound datagram to the request stream's datagram reader. + /// + /// - Precondition: Must be called on the connection channel's event loop. + func receive(_ payload: ByteBuffer) { + _ = self.inboundContinuation.yield(payload) + } + + /// Ends the datagram stream. + /// + /// - Precondition: Must be called on the connection channel's event loop. + func finish() { + self.inboundContinuation.finish() + } + + /// Writes `payload` as a datagram for this request stream. + /// + /// - Precondition: Must be called on the connection channel's event loop. + /// + /// - Note: Datagrams larger than the negotiated `max_datagram_frame_size` will be rejected by the QUIC connection + /// channel. + func write(_ payload: ByteBuffer) async throws { + try await self.connectionChannel.writeAndFlush(HTTP3Datagram(streamID: self.streamID, payload: payload)) + } +} +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift b/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift index 1370b82..f7b1268 100644 --- a/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift +++ b/Sources/NIOHTTPServer/Datagrams/NIOHTTPServer+Datagrams.swift @@ -36,11 +36,38 @@ extension NIOHTTPServer { public typealias ReadFailure = any Error public typealias FinalElement = Void + /// The iterator over the inbound datagrams. + private var iterator: AsyncStream.AsyncIterator + + /// A reusable buffer handed to the body closure on each call to ``read(body:)``. + private var buffer: UniqueArray + + init(iterator: AsyncStream.AsyncIterator) { + self.iterator = iterator + self.buffer = UniqueArray() + } + public mutating func read( body: (inout Buffer, consuming FinalElement?) async throws(Failure) -> Return ) async throws(EitherError) -> Return { - // TODO: The datagram transport is not yet implemented. - throw .first(DatagramsError.notImplemented) + let payload = await self.iterator.next(isolation: #isolation) + + self.buffer.removeAll(keepingCapacity: true) + let finalElement: Void? + if let payload { + self.buffer.reserveCapacity(payload.readableBytes) + self.buffer.append(copying: payload.readableBytesUInt8Span) + finalElement = nil + } else { + // No more datagrams will be delivered on this stream. + finalElement = () + } + + do { + return try await body(&self.buffer, finalElement) + } catch { + throw .second(error) + } } } @@ -50,19 +77,26 @@ extension NIOHTTPServer { public typealias WriteFailure = any Error public typealias FinalElement = Void + /// The unreliable datagram stream to write to. + private let unreliableStream: HTTP3UnreliableDatagramStream + + init(unreliableStream: HTTP3UnreliableDatagramStream) { + self.unreliableStream = unreliableStream + } + public mutating func write & ~Copyable>( buffer: inout Buffer ) async throws where Buffer.Element: ~Copyable { - // TODO: The datagram transport is not yet implemented. - throw DatagramsError.notImplemented + try await self.unreliableStream.write(ByteBuffer(draining: &buffer)) } public consuming func finish & ~Copyable>( buffer: inout Buffer, finalElement: consuming Void ) async throws where Buffer.Element: ~Copyable { - // TODO: The datagram transport is not yet implemented. - throw DatagramsError.notImplemented + if !buffer.isEmpty { + try await self.unreliableStream.write(ByteBuffer(draining: &buffer)) + } } } } diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 1265b01..e0cf3c8 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -70,6 +70,9 @@ its respective key prefix. | `http.http3.quicConfiguration.qlog` | `path` | `string` | Optional | nil | | | `topic` | `string` | Optional | nil | | | `description` | `string` | Optional | nil | +| `http.http3.datagramConfiguration` | `datagramsEnabled` | `bool` | Optional | true | +| `http.http3.datagramConfiguration` | `maxDatagramFrameSize` | `int` (bytes) | Optional | 65535 | +| `http.http3.datagramConfiguration` | `maxBufferedDatagrams` | `int` | Optional | 16 | | `transportSecurity` | `mode` | `string` | Required (permitted values: `"plaintext"`, `"tls"`, `"mTLS"`) | - | | | `credentialSource` | `string` | Required for `"tls"` and `"mTLS"` (permitted values: `"inline"`, `"file"`, `"rawPublicKey"`) | - | | | `certificateChainPEMString` | `string` | Required for `credentialSource: "inline"` | - | diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index bf9f105..e165acb 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -51,12 +51,7 @@ extension NIOHTTPServer { ) #if HTTP3 - case http3( - connection: HTTP3ServerConnection< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - > - ) + case http3(connection: HTTP3ServerConnection) #endif } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index 09ae926..0a2bf6b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -27,11 +27,19 @@ import X509 @available(anyAppleOS 26.0, *) extension NIOHTTPServer { + /// An inbound HTTP/3 request stream. + struct HTTP3Stream: Sendable { + /// The stream channel. + var channel: NIOAsyncChannel + + #if UnstableHTTPDatagrams + /// The unreliable datagram stream. `nil` if HTTP datagram support was not negotiated. + var datagramStream: HTTP3UnreliableDatagramStream? + #endif + } + func serveHTTP3( - connectionMultiplexer: HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - >, + connectionMultiplexer: HTTP3ServerConnectionMultiplexer, connectionHandler: Handler ) async { // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child task @@ -50,10 +58,7 @@ extension NIOHTTPServer { /// Builds the per-connection ``Connection`` and ``ConnectionContext`` for a HTTP/3 connection channel and /// dispatches the connection to the connection handler. Errors from the connection handler are logged. func dispatchHTTP3Connection( - _ http3Connection: HTTP3ServerConnection< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - >, + _ http3Connection: HTTP3ServerConnection, handler: Handler ) async { let context = ConnectionContext( @@ -84,10 +89,7 @@ extension NIOHTTPServer { /// /// - Note: Stream iteration errors are logged but do not propagate to the caller. func handleHTTP3Connection( - connection: HTTP3ServerConnection< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - >, + connection: HTTP3ServerConnection, handler: Handler, context: ConnectionContext ) async @@ -97,9 +99,18 @@ extension NIOHTTPServer { Handler.ResponseSender == ResponseSender { await withDiscardingTaskGroup { streamGroup in - for await streamChannel in connection.inboundStreams { + for await stream in connection.inboundStreams { streamGroup.addTask { - await self.handleStreamChannel(channel: streamChannel, handler: handler, context: context) + #if UnstableHTTPDatagrams + await self.handleStreamChannel( + channel: stream.channel, + handler: handler, + context: context, + datagramStream: stream.datagramStream + ) + #else + await self.handleStreamChannel(channel: stream.channel, handler: handler, context: context) + #endif } } } @@ -114,22 +125,12 @@ extension NIOHTTPServer { authenticator: NIOQUIC.Authenticator? ) async throws -> [( quicChannel: any Channel, - connectionMultiplexer: HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - > + connectionMultiplexer: HTTP3ServerConnectionMultiplexer )] { let bootstrap = DatagramBootstrap(group: .singletonMultiThreadedEventLoopGroup) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - var serverChannels = [ - ( - any Channel, - HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, NIOQUIC.QUICStreamCreator - > - ) - ]() + var serverChannels = [(any Channel, HTTP3ServerConnectionMultiplexer)]() do { for bindTarget in bindTargets { switch bindTarget.backing { @@ -168,14 +169,9 @@ extension NIOHTTPServer { authenticator: NIOQUIC.Authenticator? ) throws -> ( quicChannel: any Channel, - connectionMultiplexer: HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, NIOQUIC.QUICStreamCreator - > + connectionMultiplexer: HTTP3ServerConnectionMultiplexer ) { - let connectionMultiplexer = HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - >() + let connectionMultiplexer = HTTP3ServerConnectionMultiplexer() let quicHandler = QUICHandler( channel: channel, @@ -218,20 +214,44 @@ extension NIOHTTPServer { http3Configuration: NIOHTTPServerConfiguration.HTTP3, connectionChannel: any Channel, streamCreator: NIOQUIC.QUICStreamCreator, - ) throws -> HTTP3ServerConnection< - NIOAsyncChannel, - NIOQUIC.QUICStreamCreator - > { + ) throws -> HTTP3ServerConnection { let loopBoundHandler = NIOLoopBoundBox?>( nil, eventLoop: connectionChannel.eventLoop ) + #if UnstableHTTPDatagrams + // TODO: If support for datagrams was not negotiated, we shouldn't create the datagram handler and demultiplexer. + let demultiplexer = NIOLoopBound(HTTP3DatagramDemultiplexer(), eventLoop: connectionChannel.eventLoop) + #endif + let connection = HTTP3ServerConnection(connectionHandler: loopBoundHandler) { streamInitializerParameters in let streamChannel = streamInitializerParameters.channel return streamChannel.eventLoop.makeCompletedFuture { - try self.setupHTTP3Stream(streamChannel: streamChannel) + #if !UnstableHTTPDatagrams + return HTTP3Stream(channel: try self.setupHTTP3Stream(streamChannel: streamChannel)) + #else + guard let datagramConfiguration = http3Configuration.datagramConfiguration else { + return HTTP3Stream(channel: try self.setupHTTP3Stream(streamChannel: streamChannel)) + } + + let datagramStream = HTTP3UnreliableDatagramStream( + streamID: streamInitializerParameters.streamID, + connectionChannel: connectionChannel, + maxBufferedDatagrams: datagramConfiguration.maxBufferedDatagrams + ) + demultiplexer.value.register(datagramStream: datagramStream) + + streamChannel.closeFuture.whenComplete { _ in + demultiplexer.value.deregister(streamID: datagramStream.streamID) + datagramStream.finish() + } + return HTTP3Stream( + channel: try self.setupHTTP3Stream(streamChannel: streamChannel), + datagramStream: datagramStream + ) + #endif } } @@ -262,6 +282,10 @@ extension NIOHTTPServer { loopBoundHandler.value = http3Handler try connectionChannel.pipeline.syncOperations.addHandler(http3Handler) + #if UnstableHTTPDatagrams + try connectionChannel.pipeline.syncOperations.addHandlers([HTTP3DatagramHandler(), demultiplexer.value]) + #endif + return connection } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index b8fabf4..5414a51 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -366,6 +366,64 @@ extension NIOHTTPServer { } } + #if HTTP3 && UnstableHTTPDatagrams + /// Handles a stream channel. + /// + /// - Parameter datagramStream: The unreliable datagram stream if both the client and server agreed on + /// sending/receiving unreliable datagrams. + func handleStreamChannel( + channel: NIOAsyncChannel, + handler: Handler, + context: ConnectionContext, + datagramStream: HTTP3UnreliableDatagramStream? + ) async + where + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.ResponseSender == ResponseSender + { + do { + try await channel.executeThenClose { inbound, outbound in + var iterator = inbound.makeAsyncIterator() + + guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { + outbound.finish() + return + } + + let requestContext = RequestContext(connectionContext: context, channel: channel.channel) + + _ = await self.invokeHandler( + request: httpRequest, + iterator: iterator, + outbound: outbound, + requestContext: requestContext, + datagramStream: datagramStream, + handler: handler + ) + + // 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. + outbound.finish() + try await channel.channel.closeFuture.get() + } + } catch { + self.logger.debug( + "Error thrown while handling stream", + error: error, + metadata: [LoggingKeys.protocol: "\(context.httpVersion)"] + ) + try? await channel.channel.close() + } + } + #endif // HTTP3 && UnstableHTTPDatagrams + /// Handles a stream channel, which carries exactly one request per stream. func handleStreamChannel( channel: NIOAsyncChannel, diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 99598b5..7d020d5 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -345,6 +345,71 @@ public struct NIOHTTPServer: HTTPServer { } } + #if HTTP3 && UnstableHTTPDatagrams + /// Invokes the request handler with the appropriate reader/writer state. + func invokeHandler( + request: HTTPRequest, + iterator: consuming sending NIOAsyncChannelInboundStream.AsyncIterator, + outbound: NIOAsyncChannelOutboundWriter, + requestContext: RequestContext, + datagramStream: HTTP3UnreliableDatagramStream?, + handler: Handler + ) async + where + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.ResponseSender == ResponseSender + { + let readerState = Reader.ReaderState(iterator: iterator) + let writerState = ResponseSender.WriterState() + + let datagramReader: DatagramReader? + let datagramWriter: DatagramWriter? + + if let datagramStream { + datagramReader = DatagramReader(iterator: datagramStream.inbound.makeAsyncIterator()) + datagramWriter = DatagramWriter(unreliableStream: datagramStream) + } else { + datagramReader = nil + datagramWriter = nil + } + + let requestReader = Reader(readerState: readerState, datagramReader: datagramReader) + let responseSender = ResponseSender(writer: outbound, writerState: writerState, datagramWriter: datagramWriter) + + do { + try await handler.handle( + request: request, + requestContext: requestContext, + reader: requestReader, + responseSender: responseSender + ) + } catch { + // 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 }) { + Self.abortRequest(requestContext: requestContext, error: error) + } + } + + if !writerState.wrapped.withLock({ $0.finishedWriting }) { + self.logger.debug("Handler did not conclude the response.") + } + } + #endif + /// Shared core: invokes the request handler with the appropriate reader/writer state. /// Returns the recovered iterator if the request was fully consumed (for HTTP/1.1 reuse), /// or `nil` if the request could not be fully consumed. @@ -363,15 +428,8 @@ public struct NIOHTTPServer: HTTPServer { let readerState = Reader.ReaderState(iterator: iterator) let writerState = ResponseSender.WriterState() - #if HTTP3 && UnstableHTTPDatagrams - // TODO: `swift-nio-http3` currently does not provide APIs for reading/writing bytes on the unreliable datagram - // stream. This is why we currently pass `nil` to the `datagramReader` and `datagramWriter` arguments. - let requestReader = Reader(readerState: readerState, datagramReader: nil) - let responseSender = ResponseSender(writer: outbound, writerState: writerState, datagramWriter: nil) - #else let requestReader = Reader(readerState: readerState) let responseSender = ResponseSender(writer: outbound, writerState: writerState) - #endif do { try await handler.handle( diff --git a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift index 7e83b9f..08264aa 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServerResponseSender.swift @@ -117,24 +117,7 @@ extension NIOHTTPServer.ResponseSender { public mutating func write( buffer: inout some RangeReplaceableContainer & ~Copyable ) async throws(WriteFailure) { - var byteBuffer = ByteBuffer() - byteBuffer.reserveCapacity(buffer.count) - - var consumer = buffer.consumeAll() - // `while !done { ... }` instead of `while true { ... break }` to - // dodge a SIL ownership-verifier crash on the nightly main - // toolchain (https://github.com/swiftlang/swift/issues/89639). - var done = false - while !done { - let span = consumer.drainNext() - if span.isEmpty { - done = true - } else { - byteBuffer.writeBytes(span.span.bytes) - } - } - - try await self.writer.write(.body(byteBuffer)) + try await self.writer.write(.body(ByteBuffer(draining: &buffer))) } public consuming func finish( @@ -142,24 +125,7 @@ extension NIOHTTPServer.ResponseSender { finalElement: consuming HTTPFields? ) async throws(WriteFailure) { if !buffer.isEmpty { - var byteBuffer = ByteBuffer() - byteBuffer.reserveCapacity(buffer.count) - - var consumer = buffer.consumeAll() - // `while !done { ... }` instead of `while true { ... break }` to - // dodge a SIL ownership-verifier crash on the nightly main - // toolchain (https://github.com/swiftlang/swift/issues/89639). - var done = false - while !done { - let span = consumer.drainNext() - if span.isEmpty { - done = true - } else { - byteBuffer.writeBytes(span.span.bytes) - } - } - - try await self.writer.write(.body(byteBuffer)) + try await self.writer.write(.body(ByteBuffer(draining: &buffer))) } try await self.writer.write(.end(finalElement)) self.writerState.wrapped.withLock { $0.finishedWriting = true } @@ -182,3 +148,26 @@ extension NIOHTTPServer.ResponseSender: Sendable {} @available(*, unavailable) extension NIOHTTPServer.ResponseSender.Writer: Sendable {} + +extension ByteBuffer { + /// Drains `buffer` into a newly allocated `ByteBuffer`. + init & ~Copyable>( + draining buffer: inout Buffer + ) where Buffer.Element: ~Copyable { + self.init() + self.reserveCapacity(buffer.count) + + var consumer = buffer.consumeAll() + // `while !done { ... }` instead of `while true { ... break }` to dodge a SIL ownership-verifier crash on the + // nightly main toolchain (https://github.com/swiftlang/swift/issues/89639). + var done = false + while !done { + let span = consumer.drainNext() + if span.isEmpty { + done = true + } else { + self.writeBytes(span.span.bytes) + } + } + } +} diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift index c856e50..755f690 100644 --- a/Sources/NIOHTTPServer/ServerChannel.swift +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -38,10 +38,7 @@ extension NIOHTTPServer { #if HTTP3 case http3( quicChannel: any Channel, - connectionMultiplexer: HTTP3ServerConnectionMultiplexer< - NIOAsyncChannel, - QUICStreamCreator - > + connectionMultiplexer: HTTP3ServerConnectionMultiplexer ) #endif } diff --git a/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift b/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift new file mode 100644 index 0000000..017642c --- /dev/null +++ b/Tests/NIOHTTPServerTests/HTTP3DatagramTests.swift @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// 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 && UnstableHTTPDatagrams + +import BasicContainers +import NIOCore +import NIOEmbedded +import NIOHTTP3 +import NIOQUICHelpers +import Testing + +@testable import NIOHTTPServer + +@Suite +struct HTTP3DatagramTests { + @Test("Datagrams are routed to the request stream they belong to") + @available(anyAppleOS 26.0, *) + func datagramsAreRoutedByStreamID() async throws { + let channel = EmbeddedChannel() + let demultiplexer = HTTP3DatagramDemultiplexer() + try channel.pipeline.syncOperations.addHandler(demultiplexer) + + let first = HTTP3UnreliableDatagramStream(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + let second = HTTP3UnreliableDatagramStream(streamID: 2, connectionChannel: channel, maxBufferedDatagrams: 16) + demultiplexer.register(datagramStream: first) + demultiplexer.register(datagramStream: second) + + var firstReader = NIOHTTPServer.DatagramReader(iterator: first.inbound.makeAsyncIterator()) + var secondReader = NIOHTTPServer.DatagramReader(iterator: second.inbound.makeAsyncIterator()) + + try channel.writeInbound(HTTP3Datagram(streamID: 0, payload: ByteBuffer([1, 2, 3]))) + try channel.writeInbound(HTTP3Datagram(streamID: 2, payload: ByteBuffer([4, 5, 6]))) + // Nothing is registered for stream 4, so this datagram should be dropped. + try channel.writeInbound(HTTP3Datagram(streamID: 4, payload: ByteBuffer([2]))) + + #expect(try await TestHelpers.readDatagram(&firstReader) == [1, 2, 3]) + #expect(try await TestHelpers.readDatagram(&secondReader) == [4, 5, 6]) + } + + @Test("Datagrams delivered after deregistering are dropped") + @available(anyAppleOS 26.0, *) + func datagramsAfterDeregisteringAreDropped() async throws { + let channel = EmbeddedChannel() + let demultiplexer = HTTP3DatagramDemultiplexer() + try channel.pipeline.syncOperations.addHandler(demultiplexer) + + let stream = HTTP3UnreliableDatagramStream(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + demultiplexer.register(datagramStream: stream) + var reader = NIOHTTPServer.DatagramReader(iterator: stream.inbound.makeAsyncIterator()) + + demultiplexer.deregister(streamID: stream.streamID) + try channel.writeInbound(HTTP3Datagram(streamID: 0, payload: ByteBuffer([1]))) + stream.finish() + + #expect(try await TestHelpers.readDatagram(&reader) == nil) + } + + @Test("Closing the connection ends every registered datagram stream") + @available(anyAppleOS 26.0, *) + func closingTheConnectionEndsEveryDatagramStream() async throws { + let channel = EmbeddedChannel() + let demultiplexer = HTTP3DatagramDemultiplexer() + try channel.pipeline.syncOperations.addHandler(demultiplexer) + + let first = HTTP3UnreliableDatagramStream(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + let second = HTTP3UnreliableDatagramStream(streamID: 16, connectionChannel: channel, maxBufferedDatagrams: 16) + demultiplexer.register(datagramStream: first) + demultiplexer.register(datagramStream: second) + + var firstReader = NIOHTTPServer.DatagramReader(iterator: first.inbound.makeAsyncIterator()) + var secondReader = NIOHTTPServer.DatagramReader(iterator: second.inbound.makeAsyncIterator()) + + let leftOverState = try channel.finish() + + #expect(leftOverState.isClean) + #expect(try await TestHelpers.readDatagram(&firstReader) == nil) + #expect(try await TestHelpers.readDatagram(&secondReader) == nil) + } + + @Test("The oldest datagram is dropped once the buffer is full") + @available(anyAppleOS 26.0, *) + func theOldestDatagramIsDroppedOnceTheBufferIsFull() async throws { + let channel = EmbeddedChannel() + let stream = HTTP3UnreliableDatagramStream(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 2) + + for byte in UInt8(1)...4 { + stream.receive(ByteBuffer([byte])) + } + + // The two newest datagrams are kept. + var reader = NIOHTTPServer.DatagramReader(iterator: stream.inbound.makeAsyncIterator()) + #expect(try await TestHelpers.readDatagram(&reader) == [3]) + #expect(try await TestHelpers.readDatagram(&reader) == [4]) + } + + @Test("Each write is sent as one datagram containing the stream's ID") + @available(anyAppleOS 26.0, *) + func eachWriteIsSentAsOneDatagram() async throws { + let channel = EmbeddedChannel() + + var streamZeroWriter = NIOHTTPServer.DatagramWriter( + unreliableStream: .init(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + ) + var streamFourWriter = NIOHTTPServer.DatagramWriter( + unreliableStream: .init(streamID: 4, connectionChannel: channel, maxBufferedDatagrams: 16) + ) + + var streamZeroFirstBuffer = UniqueArray(copying: [1]) + var streamZeroSecondBuffer = UniqueArray(copying: [2]) + try await streamZeroWriter.write(buffer: &streamZeroFirstBuffer) + try await streamZeroWriter.write(buffer: &streamZeroSecondBuffer) + + var streamTwoFirstBuffer = UniqueArray(copying: [3]) + var streamTwoSecondBuffer = UniqueArray(copying: [4]) + try await streamFourWriter.write(buffer: &streamTwoFirstBuffer) + try await streamFourWriter.write(buffer: &streamTwoSecondBuffer) + + let streamZeroFirstDatagram = try channel.readOutbound(as: HTTP3Datagram.self) + let streamZeroSecondDatagram = try channel.readOutbound(as: HTTP3Datagram.self) + + let streamFourFirstDatagram = try channel.readOutbound(as: HTTP3Datagram.self) + let streamFourSecondDatagram = try channel.readOutbound(as: HTTP3Datagram.self) + + #expect(streamZeroFirstDatagram?.streamID == 0) + #expect(streamZeroFirstDatagram?.payload == .init([1])) + #expect(streamZeroSecondDatagram?.streamID == 0) + #expect(streamZeroSecondDatagram?.payload == .init([2])) + + #expect(streamFourFirstDatagram?.streamID == 4) + #expect(streamFourFirstDatagram?.payload == .init([3])) + #expect(streamFourSecondDatagram?.streamID == 4) + #expect(streamFourSecondDatagram?.payload == .init([4])) + } + + @Test("Finishing flushes any remaining bytes as a final datagram") + @available(anyAppleOS 26.0, *) + func finishingWritesRemainingBytes() async throws { + let channel = EmbeddedChannel() + let writer = NIOHTTPServer.DatagramWriter( + unreliableStream: .init(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + ) + + var buffer = UniqueArray(copying: [7, 8]) + try await writer.finish(buffer: &buffer, finalElement: ()) + + let datagram = try channel.readOutbound(as: HTTP3Datagram.self) + #expect(datagram?.streamID == 0) + #expect(datagram?.payload == ByteBuffer([7, 8])) + } + + @Test("Finishing with an empty buffer writes no datagram") + @available(anyAppleOS 26.0, *) + func finishingWithAnEmptyBufferWritesNoDatagram() async throws { + let channel = EmbeddedChannel() + let writer = NIOHTTPServer.DatagramWriter( + unreliableStream: .init(streamID: 0, connectionChannel: channel, maxBufferedDatagrams: 16) + ) + + var buffer = UniqueArray() + try await writer.finish(buffer: &buffer, finalElement: ()) + + #expect(try channel.readOutbound(as: HTTP3Datagram.self) == nil) + } + + @Test("Reading and writing datagrams are independent of each other") + @available(anyAppleOS 26.0, *) + func readingAndWritingAreIndependent() async throws { + let channel = EmbeddedChannel() + let stream = HTTP3UnreliableDatagramStream(streamID: 4, connectionChannel: channel, maxBufferedDatagrams: 16) + var reader = NIOHTTPServer.DatagramReader(iterator: stream.inbound.makeAsyncIterator()) + var writer = NIOHTTPServer.DatagramWriter(unreliableStream: stream) + + stream.receive(ByteBuffer([1])) + var buffer = UniqueArray(copying: [2]) + try await writer.write(buffer: &buffer) + + #expect(try await TestHelpers.readDatagram(&reader) == [1]) + + let datagram = try channel.readOutbound(as: HTTP3Datagram.self) + #expect(datagram?.streamID == 4) + #expect(datagram?.payload == ByteBuffer([2])) + } +} +#endif // HTTP3 && UnstableHTTPDatagrams diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift index 4c1fd03..75b6112 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerReaderTests.swift @@ -239,9 +239,10 @@ struct NIOHTTPServerReaderTests { source.yield(.end(nil)) source.finish() + let (datagrams, datagramSource) = AsyncStream.makeStream() var requestBodyReader = NIOHTTPServer.Reader( readerState: .init(iterator: stream.makeAsyncIterator()), - datagramReader: NIOHTTPServer.DatagramReader() + datagramReader: NIOHTTPServer.DatagramReader(iterator: datagrams.makeAsyncIterator()) ) let datagramReader = requestBodyReader.takeDatagramReader() @@ -256,13 +257,10 @@ struct NIOHTTPServerReaderTests { return } - // TODO: The underlying unreliable datagrams transport is not yet implemented. - let error = try await #require(throws: EitherError.self) { - try await datagramReader.read { _, _ in } - } - try #require(throws: DatagramsError.notImplemented) { try error.unwrap() } + datagramSource.yield(ByteBuffer(bytes: [4, 5])) #expect(collected == [1, 2, 3]) + #expect(try await TestHelpers.readDatagram(&datagramReader) == [4, 5]) } #endif // HTTP3 && UnstableHTTPDatagrams } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 5bf4cfb..28b5a17 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -388,6 +388,9 @@ struct NIOHTTPServerSwiftConfigurationTests { "quicConfiguration.qlog.path": "/tmp/qlog", "quicConfiguration.qlog.topic": "topic", "quicConfiguration.qlog.description": "description", + "datagramConfiguration.datagramsEnabled": true, + "datagramConfiguration.maxDatagramFrameSize": 1200, + "datagramConfiguration.maxBufferedDatagrams": 12, ]) let snapshot = ConfigReader(provider: provider).snapshot() @@ -400,6 +403,12 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(connectionSettings.qpackBlockedStreams == 16) #expect(connectionSettings.maximumFieldSectionSize == 8192) + #if UnstableHTTPDatagrams + let datagramConfiguration = try #require(http3.datagramConfiguration) + #expect(datagramConfiguration.maxDatagramFrameSize == 1200) + #expect(datagramConfiguration.maxBufferedDatagrams == 12) + #endif + let quic = http3.quicConfiguration #expect(quic.keyExchangeGroup == .secp384) #expect(quic.maxIdleTimeout == .seconds(10)) @@ -467,6 +476,67 @@ struct NIOHTTPServerSwiftConfigurationTests { } } + #if UnstableHTTPDatagrams + @Suite("DatagramConfiguration") + struct DatagramConfigurationTests { + @Test("Default values") + @available(anyAppleOS 26.0, *) + func defaultValues() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: [:])).snapshot() + + let datagramConfiguration = NIOHTTPServerConfiguration.HTTP3.DatagramConfiguration(config: snapshot) + + #expect(datagramConfiguration == .defaults) + } + + @Test("Custom values") + @available(anyAppleOS 26.0, *) + func customValues() throws { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "datagramsEnabled": true, + "maxDatagramFrameSize": 1200, + "maxBufferedDatagrams": 12, + ]) + ).snapshot() + + let config = try #require(NIOHTTPServerConfiguration.HTTP3.DatagramConfiguration(config: snapshot)) + #expect(config.maxDatagramFrameSize == 1200) + #expect(config.maxBufferedDatagrams == 12) + } + + @Test("Datagrams configuration `nil` when `datagramsEnabled` set to false") + @available(anyAppleOS 26.0, *) + func configNilWhenDatagramsEnabledSetToFalse() throws { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "datagramsEnabled": false + ]) + ).snapshot() + + #expect(NIOHTTPServerConfiguration.HTTP3.DatagramConfiguration(config: snapshot) == nil) + } + + @Test("Maximum frame size must not be set to 0 when datagrams enabled") + @available(anyAppleOS 26.0, *) + func frameSizeMustNotBeZeroWhenDatagramsEnabled() async throws { + await #expect(processExitsWith: .failure) { + // Setting `maxDatagramFrameSize` to 0 means that the QUIC layer will not advertise support for + // receiving datagrams. If users really want to disable receiving datagrams, then `datagramsEnabled` + // should be set to `false`. + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "datagramsEnabled": true, + "maxDatagramFrameSize": 0, + ]) + ).snapshot() + + _ = NIOHTTPServerConfiguration.HTTP3.DatagramConfiguration(config: snapshot) + } + } + } + #endif // UnstableHTTPDatagrams + @Suite("QUICConfiguration") struct QUICConfigurationTests { @Test("Default values") diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift index d414f18..f416cf8 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerWriterTests.swift @@ -14,11 +14,16 @@ import BasicContainers import NIOCore +import NIOEmbedded import NIOHTTPTypes import Testing @testable import NIOHTTPServer +#if HTTP3 && UnstableHTTPDatagrams +import NIOHTTP3 +#endif + @Suite struct NIOHTTPServerWriterTests { let bodySampleOne: UInt8 = 1 @@ -118,10 +123,13 @@ struct NIOHTTPServerWriterTests { @available(anyAppleOS 26.0, *) func takeDatagramWriterVendsResponseAndDatagramWriter() async throws { let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let connectionChannel = EmbeddedChannel() let sender = NIOHTTPServer.ResponseSender( writer: outboundWriter, writerState: .init(), - datagramWriter: NIOHTTPServer.DatagramWriter() + datagramWriter: NIOHTTPServer.DatagramWriter( + unreliableStream: .init(streamID: 4, connectionChannel: connectionChannel, maxBufferedDatagrams: 16) + ) ) var responseBodyWriter = try await sender.send(.init(status: .ok)) @@ -135,11 +143,8 @@ struct NIOHTTPServerWriterTests { return } - // TODO: The underlying unreliable datagrams transport is not yet implemented. - await #expect(throws: DatagramsError.notImplemented) { - var emptyBuffer = UniqueArray() - try await datagramWriter.write(buffer: &emptyBuffer) - } + var datagramBuffer = UniqueArray(copying: [1]) + try await datagramWriter.write(buffer: &datagramBuffer) var responseIterator = sink.makeAsyncIterator() let head = try #require(await responseIterator.next()) @@ -149,6 +154,10 @@ struct NIOHTTPServerWriterTests { #expect(head == .head(.init(status: .ok))) #expect(body == .body(.init(repeating: 5, count: 10))) #expect(end == .end(nil)) + + let firstDatagram = try connectionChannel.readOutbound(as: HTTP3Datagram.self) + #expect(firstDatagram?.streamID == 4) + #expect(firstDatagram?.payload == ByteBuffer([1])) } #endif // HTTP3 && UnstableHTTPDatagrams } diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index 77eddab..399ff41 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -473,6 +473,25 @@ extension TestHelpers { return (server, clientConfiguration) } + + #if HTTP3 && UnstableHTTPDatagrams + /// Reads the next datagram from `reader`, returning its bytes, or `nil` if the datagram stream has ended. + static func readDatagram(_ reader: inout NIOHTTPServer.DatagramReader) async throws -> [UInt8]? { + var bytes: [UInt8] = [] + var hasEnded = false + + try await reader.read { buffer, finalElement in + if case .some = finalElement { + hasEnded = true + } + for index in buffer.indices { + bytes.append(buffer[index]) + } + } + + return hasEnded ? nil : bytes + } + #endif // HTTP3 && UnstableHTTPDatagrams } @available(anyAppleOS 26.0, *)