diff --git a/.github/ISSUE_TEMPLATE/http2.md b/.github/ISSUE_TEMPLATE/http2.md new file mode 100644 index 0000000000..9c982e75a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/http2.md @@ -0,0 +1,5 @@ +--- +name: "package:http2" +about: "Create a bug or file a feature request against package:http2." +labels: "package:http2" +--- \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml index b1143c6f1a..3add1c1717 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -16,6 +16,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/http/**' +'package:http2': + - changed-files: + - any-glob-to-any-file: 'pkgs/http2/**' + 'package:http_parser': - changed-files: - any-glob-to-any-file: 'pkgs/http_parser/**' diff --git a/.github/workflows/http2.yaml b/.github/workflows/http2.yaml new file mode 100644 index 0000000000..eb3dc28010 --- /dev/null +++ b/.github/workflows/http2.yaml @@ -0,0 +1,70 @@ +name: package:http2 + +on: + push: + branches: + - master + paths: + - '.github/workflows/http2.yaml' + - 'pkgs/http2/**' + pull_request: + paths: + - '.github/workflows/http2.yaml' + - 'pkgs/http2/**' + schedule: + - cron: "0 0 * * 0" + +defaults: + run: + working-directory: pkgs/http2/ + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart dev. + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - name: Analyze code + run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + # Run tests on a matrix consisting of two dimensions: + # 1. OS: ubuntu-latest, (macos-latest, windows-latest) + # 2. release channel: dev + test: + needs: analyze + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Add macos-latest and/or windows-latest if relevant for this package. + os: [ubuntu-latest] + sdk: [3.2, dev] + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672 + with: + sdk: ${{ matrix.sdk }} + - id: install + name: Install dependencies + run: dart pub get + - name: Run VM tests + run: dart test --platform vm + if: always() && steps.install.outcome == 'success' diff --git a/README.md b/README.md index a5e5ad44d0..31fc6500b1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ and the browser. | [cupertino_http](pkgs/cupertino_http/) | A macOS/iOS Flutter plugin that provides access to the [Foundation URL Loading System](https://developer.apple.com/documentation/foundation/url_loading_system). | [![pub package](https://img.shields.io/pub/v/cupertino_http.svg)](https://pub.dev/packages/cupertino_http) | | [flutter_http_example](pkgs/flutter_http_example/) | An Flutter app that demonstrates how to configure and use `package:http`. | — | | [http](pkgs/http/) | A composable, multi-platform, Future-based API for HTTP requests. | [![pub package](https://img.shields.io/pub/v/http.svg)](https://pub.dev/packages/http) | +| [http2](pkgs/http2/) | A HTTP/2 implementation in Dart. | [![pub package](https://img.shields.io/pub/v/http2.svg)](https://pub.dev/packages/http2) | | [http_client_conformance_tests](pkgs/http_client_conformance_tests/) | A library that tests whether implementations of package:http's `Client` class behave as expected. | | | [http_parser](pkgs/http_parser/) | A platform-independent package for parsing and serializing HTTP formats. | [![pub package](https://img.shields.io/pub/v/http_parser.svg)](https://pub.dev/packages/http_parser) | | [http_profile](pkgs/http_profile/) | A library used by HTTP client authors to integrate with the DevTools Network View. | [![pub package](https://img.shields.io/pub/v/http_profile.svg)](https://pub.dev/packages/http_profile) | diff --git a/pkgs/http2/.gitignore b/pkgs/http2/.gitignore new file mode 100644 index 0000000000..ac98e87d12 --- /dev/null +++ b/pkgs/http2/.gitignore @@ -0,0 +1,4 @@ +# Don’t commit the following directories created by pub. +.dart_tool +.packages +pubspec.lock diff --git a/pkgs/http2/.test_config b/pkgs/http2/.test_config new file mode 100644 index 0000000000..2fa4b96b0e --- /dev/null +++ b/pkgs/http2/.test_config @@ -0,0 +1,5 @@ +{ + "test_package": { + "platforms" : ["vm"] + } +} diff --git a/pkgs/http2/AUTHORS b/pkgs/http2/AUTHORS new file mode 100644 index 0000000000..93b7228f05 --- /dev/null +++ b/pkgs/http2/AUTHORS @@ -0,0 +1,8 @@ +# Below is a list of people and organizations that have contributed +# to the project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. <*@google.com> + +Alexandre Ardhuin diff --git a/pkgs/http2/CHANGELOG.md b/pkgs/http2/CHANGELOG.md new file mode 100644 index 0000000000..6e482a92b3 --- /dev/null +++ b/pkgs/http2/CHANGELOG.md @@ -0,0 +1,115 @@ +## 2.3.1 + +- Require Dart 3.2 +- Add topics to `pubspec.yaml` +- Move to `dart-lang/http` monorepo. + +## 2.3.0 + +- Only send updates on frames and pings being received when there are listeners, as to not fill up memory. + +## 2.2.0 + +- Transform headers to lowercase. +- Expose pings to connection to enable the KEEPALIVE feature for gRPC. + +## 2.1.0 + +- Require Dart `3.0.0` +- Require Dart `2.17.0`. +- Send `WINDOW_UPDATE` frames for the connection to account for data being sent on closed streams until the `RST_STREAM` has been processed. + +## 2.0.1 + +- Simplify the implementation of `MultiProtocolHttpServer.close`. +- Require Dart `2.15.0`. + +## 2.0.0 + +* Migrate to null safety. + +## 1.0.1 + +* Add `TransportConnection.onInitialPeerSettingsReceived` which fires when + initial SETTINGS frame is received from the peer. + +## 1.0.0 + +* Graduate package to 1.0. +* `package:http2/http2.dart` now reexports `package:http2/transport.dart`. + +## 0.1.9 + +* Discard messages incoming after stream cancellation. + +## 0.1.8+2 + +* On connection termination, try to dispatch existing messages, thereby avoiding + terminating existing streams. + +* Fix `ClientTransportConnection.isOpen` to return `false` if we have exhausted + the number of max-concurrent-streams. + +## 0.1.8+1 + +* Switch all uppercase constants from `dart:convert` to lowercase. + +## 0.1.8 + +* More changes required for making tests pass under Dart 2.0 runtime. +* Modify sdk constraint to require '>=2.0.0-dev.40.0'. + +## 0.1.7 + +* Fixes for Dart 2.0. + +## 0.1.6 + +* Strong mode fixes and other cleanup. + +## 0.1.5 + +* Removed use of new `Function` syntax, since it isn't fully supported in Dart + 1.24. + +## 0.1.4 + +* Added an `onActiveStateChanged` callback to `Connection`, which is invoked when + the connection changes state from idle to active or from active to idle. This + can be used to implement an idle connection timeout. + +## 0.1.3 + +* Fixed a bug where a closed window would not open correctly due to an increase + in initial window size. + +## 0.1.2 + +* The endStream bit is now set on the requested frame, instead of on an empty + data frame following it. +* Added an `onTerminated` hook that is called when a TransportStream receives + a RST_STREAM frame. + +## 0.1.1+2 + +* Add errorCode to exception toString message. + +## 0.1.1+1 + +* Fixing a performance issue in case the underlying socket is not writeable +* Allow clients of MultiProtocolHttpServer to supply [http.ServerSettings] +* Allow the draft version 'h2-14' in the ALPN protocol negogiation. + +## 0.1.1 + +* Adding support for MultiProtocolHttpServer in the + `package:http2/multiprotocol_server.dart` library + +## 0.1.0 + +* First version of a HTTP/2 transport implementation in the + `package:http2/transport.dart` library + +## 0.0.1 + +- Initial version diff --git a/pkgs/http2/LICENSE b/pkgs/http2/LICENSE new file mode 100644 index 0000000000..dbd2843a08 --- /dev/null +++ b/pkgs/http2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2015, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/http2/README.md b/pkgs/http2/README.md new file mode 100644 index 0000000000..edb75c99d6 --- /dev/null +++ b/pkgs/http2/README.md @@ -0,0 +1,55 @@ +[![pub package](https://img.shields.io/pub/v/http2.svg)](https://pub.dev/packages/http2) +[![package publisher](https://img.shields.io/pub/publisher/http2.svg)](https://pub.dev/packages/http2/publisher) + +This library provides an http/2 interface on top of a bidirectional stream of bytes. + +## Usage + +Here is a minimal example of connecting to a http/2 capable server, requesting +a resource and iterating over the response. + +```dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:http2/http2.dart'; + +Future main() async { + final uri = Uri.parse('https://www.google.com/'); + + final transport = ClientTransportConnection.viaSocket( + await SecureSocket.connect( + uri.host, + uri.port, + supportedProtocols: ['h2'], + ), + ); + + final stream = transport.makeRequest( + [ + Header.ascii(':method', 'GET'), + Header.ascii(':path', uri.path), + Header.ascii(':scheme', uri.scheme), + Header.ascii(':authority', uri.host), + ], + endStream: true, + ); + + await for (var message in stream.incomingMessages) { + if (message is HeadersStreamMessage) { + for (var header in message.headers) { + final name = utf8.decode(header.name); + final value = utf8.decode(header.value); + print('Header: $name: $value'); + } + } else if (message is DataStreamMessage) { + // Use [message.bytes] (but respect 'content-encoding' header) + } + } + await transport.finish(); +} +``` + +An example with better error handling is available [here][example]. + +See the [API docs][api] for more details. diff --git a/pkgs/http2/analysis_options.yaml b/pkgs/http2/analysis_options.yaml new file mode 100644 index 0000000000..9f9fe93611 --- /dev/null +++ b/pkgs/http2/analysis_options.yaml @@ -0,0 +1,9 @@ +# https://dart.dev/tools/analysis#the-analysis-options-file +include: package:dart_flutter_team_lints/analysis_options.yaml + +analyzer: + language: + strict-casts: true + errors: + # Disabled as there are several dozen violations. + constant_identifier_names: ignore diff --git a/pkgs/http2/dart_test.yaml b/pkgs/http2/dart_test.yaml new file mode 100644 index 0000000000..7bcbfc9ae6 --- /dev/null +++ b/pkgs/http2/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + flaky: # Tests that should be run as a separate job on Travis diff --git a/pkgs/http2/example/display_headers.dart b/pkgs/http2/example/display_headers.dart new file mode 100644 index 0000000000..42dbf22b79 --- /dev/null +++ b/pkgs/http2/example/display_headers.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http2/transport.dart'; + +void main(List args) async { + if (args.length != 1) { + print('Usage: dart display_headers.dart '); + exit(1); + } + + var uriArg = args[0]; + + if (!uriArg.startsWith('https://')) { + print('URI must start with https://'); + exit(1); + } + + var uri = Uri.parse(uriArg); + + var socket = await connect(uri); + + // The default client settings will disable server pushes. We + // therefore do not need to deal with [stream.peerPushes]. + var transport = ClientTransportConnection.viaSocket(socket); + + var headers = [ + Header.ascii(':method', 'GET'), + Header.ascii(':path', uri.path), + Header.ascii(':scheme', uri.scheme), + Header.ascii(':authority', uri.host), + ]; + + var stream = transport.makeRequest(headers, endStream: true); + await for (var message in stream.incomingMessages) { + if (message is HeadersStreamMessage) { + for (var header in message.headers) { + var name = utf8.decode(header.name); + var value = utf8.decode(header.value); + print('$name: $value'); + } + } else if (message is DataStreamMessage) { + // Use [message.bytes] (but respect 'content-encoding' header) + } + } + await transport.finish(); +} + +Future connect(Uri uri) async { + var useSSL = uri.scheme == 'https'; + if (useSSL) { + var secureSocket = await SecureSocket.connect(uri.host, uri.port, + supportedProtocols: ['h2']); + if (secureSocket.selectedProtocol != 'h2') { + throw Exception('Failed to negogiate http/2 via alpn. Maybe server ' + "doesn't support http/2."); + } + return secureSocket; + } else { + return await Socket.connect(uri.host, uri.port); + } +} diff --git a/pkgs/http2/lib/http2.dart b/pkgs/http2/lib/http2.dart new file mode 100644 index 0000000000..3f1ed78404 --- /dev/null +++ b/pkgs/http2/lib/http2.dart @@ -0,0 +1,48 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// This library provides an http/2 interface on top of a bidirectional stream +/// of bytes. +/// +/// The client and server sides can be created via [ClientTransportStream] and +/// [ServerTransportStream] respectively. Both sides can be configured via +/// settings (see [ClientSettings] and [ServerSettings]). The settings will be +/// communicated to the remote peer (if necessary) and will be valid during the +/// entire lifetime of the connection. +/// +/// A http/2 transport allows a client to open a bidirectional stream (see +/// [ClientTransportConnection.makeRequest]) and a server can open (or push) a +/// unidirectional stream to the client via [ServerTransportStream.push]. +/// +/// In both cases (unidirectional and bidirectional stream), one can send +/// headers and data to the other side (via [HeadersStreamMessage] and +/// [DataStreamMessage]). These messages are ordered and will arrive in the same +/// order as they were sent (data messages may be split up into multiple smaller +/// chunks or might be combined). +/// +/// In the most common case, each direction will send one [HeadersStreamMessage] +/// followed by zero or more [DataStreamMessage]s. +/// +/// Establishing a bidirectional stream of bytes to a server is up to the user +/// of this library. There are 3 common ways to achive this +/// +/// * connect to a server via SSL and use the ALPN (SSL) protocol extension +/// to negotiate with the server to speak http/2 (the ALPN protocol +/// identifier for http/2 is `h2`) +/// +/// * have prior knowledge about the server - i.e. know ahead of time that +/// the server will speak http/2 via an unencrypted tcp connection +/// +/// * use a http/1.1 connection and upgrade it to http/2 +/// +/// The first way is the most common way and can be done in Dart by using +/// `dart:io`s secure socket implementation (by using a `SecurityContext` and +/// including 'h2' in the list of protocols used for ALPN). +/// +/// A simple example on how to connect to a http/2 capable server and +/// requesting a resource is available at https://github.com/dart-lang/http2/blob/master/example/display_headers.dart. +library http2.http2; + +import 'transport.dart'; +export 'transport.dart'; diff --git a/pkgs/http2/lib/multiprotocol_server.dart b/pkgs/http2/lib/multiprotocol_server.dart new file mode 100644 index 0000000000..54fe6c0a60 --- /dev/null +++ b/pkgs/http2/lib/multiprotocol_server.dart @@ -0,0 +1,120 @@ +// Copyright (c) 2016 the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'src/artificial_server_socket.dart'; +import 'transport.dart' as http2; + +/// Handles protocol negotiation with HTTP/1.1 and HTTP/2 clients. +/// +/// Given a (host, port) pair and a [SecurityContext], [MultiProtocolHttpServer] +/// will negotiate with the client whether HTTP/1.1 or HTTP/2 should be spoken. +/// +/// The user must supply 2 callback functions to [startServing], which: +/// * one handles HTTP/1.1 clients (called with a [HttpRequest]) +/// * one handles HTTP/2 clients (called with a [http2.ServerTransportStream]) +class MultiProtocolHttpServer { + final SecureServerSocket _serverSocket; + final http2.ServerSettings? _settings; + + late _ServerSocketController _http11Controller; + late HttpServer _http11Server; + + final StreamController _http2Controller = + StreamController(); + Stream get _http2Server => + _http2Controller.stream; + + final _http2Connections = {}; + + MultiProtocolHttpServer._(this._serverSocket, this._settings) { + _http11Controller = + _ServerSocketController(_serverSocket.address, _serverSocket.port); + _http11Server = HttpServer.listenOn(_http11Controller.stream); + } + + /// Binds a new [SecureServerSocket] with a security [context] at [port] and + /// [address] (see [SecureServerSocket.bind] for a description of supported + /// types for [address]). + /// + /// Optionally [settings] can be supplied which will be used for HTTP/2 + /// clients. + /// + /// See also [startServing]. + static Future bind( + Object? address, int port, SecurityContext context, + {http2.ServerSettings? settings}) async { + context.setAlpnProtocols(['h2', 'h2-14', 'http/1.1'], true); + var secureServer = await SecureServerSocket.bind(address, port, context); + return MultiProtocolHttpServer._(secureServer, settings); + } + + /// The port this multi-protocol HTTP server runs on. + int get port => _serverSocket.port; + + /// The address this multi-protocol HTTP server runs on. + InternetAddress get address => _serverSocket.address; + + /// Starts listening for HTTP/1.1 and HTTP/2 clients and calls the given + /// callbacks for new clients. + /// + /// It is expected that [callbackHttp11] and [callbackHttp2] will never throw + /// an exception (i.e. these must take care of error handling themselves). + void startServing(void Function(HttpRequest) callbackHttp11, + void Function(http2.ServerTransportStream) callbackHttp2, + {void Function(dynamic error, StackTrace)? onError}) { + // 1. Start listening on the real [SecureServerSocket]. + _serverSocket.listen((SecureSocket socket) { + var protocol = socket.selectedProtocol; + if (protocol == null || protocol == 'http/1.1') { + _http11Controller.addHttp11Socket(socket); + } else if (protocol == 'h2' || protocol == 'h2-14') { + var connection = http2.ServerTransportConnection.viaSocket(socket, + settings: _settings); + _http2Connections.add(connection); + connection.incomingStreams.listen(_http2Controller.add, + onError: onError, + onDone: () => _http2Connections.remove(connection)); + } else { + socket.destroy(); + throw Exception('Unexpected negotiated ALPN protocol: $protocol.'); + } + }, onError: onError); + + // 2. Drain all incoming http/1.1 and http/2 connections and call the + // respective handlers. + _http11Server.listen(callbackHttp11); + _http2Server.listen(callbackHttp2); + } + + /// Closes this [MultiProtocolHttpServer]. + /// + /// Completes once everything has been successfully shut down. + Future close({bool force = false}) => + _serverSocket.close().whenComplete(() => Future.wait([ + _http11Server.close(force: force), + for (var c in _http2Connections) force ? c.terminate() : c.finish() + ])); +} + +/// An internal helper class. +class _ServerSocketController { + final InternetAddress address; + final int port; + final StreamController _controller = StreamController(); + + _ServerSocketController(this.address, this.port); + + ArtificialServerSocket get stream { + return ArtificialServerSocket(address, port, _controller.stream); + } + + void addHttp11Socket(Socket socket) { + _controller.add(socket); + } + + Future close() => _controller.close(); +} diff --git a/pkgs/http2/lib/src/artificial_server_socket.dart b/pkgs/http2/lib/src/artificial_server_socket.dart new file mode 100644 index 0000000000..1a486fe062 --- /dev/null +++ b/pkgs/http2/lib/src/artificial_server_socket.dart @@ -0,0 +1,35 @@ +// Copyright (c) 2016 the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +/// Custom implementation of the [ServerSocket] interface. +/// +/// This class can be used to create a [ServerSocket] using [Stream] and +/// a [InternetAddress] and `port` (an example use case is to filter [Socket]s +/// and keep the [ServerSocket] interface for APIs that expect it, +/// e.g. `new HttpServer.listenOn()`). +class ArtificialServerSocket extends StreamView + implements ServerSocket { + ArtificialServerSocket(this.address, this.port, Stream stream) + : super(stream); + + // ######################################################################## + // These are the methods of [ServerSocket] in addition to [Stream]. + // ######################################################################## + + @override + final InternetAddress address; + + @override + final int port; + + /// Closing of an [ArtificialServerSocket] is not possible and an exception + /// will be thrown when calling this method. + @override + Future close() async { + throw Exception('Did not expect close() to be called.'); + } +} diff --git a/pkgs/http2/lib/src/async_utils/async_utils.dart b/pkgs/http2/lib/src/async_utils/async_utils.dart new file mode 100644 index 0000000000..22a73e99ef --- /dev/null +++ b/pkgs/http2/lib/src/async_utils/async_utils.dart @@ -0,0 +1,128 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; + +/// An interface for `StreamSink`-like classes to indicate whether adding data +/// would be buffered and when the buffer is empty again. +class BufferIndicator { + final StreamController _controller = StreamController.broadcast(sync: true); + + /// A state variable indicating whether buffereing would occur at the moment. + bool _wouldBuffer = true; + + /// Indicates whether calling [BufferedBytesWriter.add] would buffer the data + /// if called. + /// + /// This can be used at a higher level as a way to do custom buffering and + /// possibly prioritization. + bool get wouldBuffer { + return _wouldBuffer; + } + + /// Signals that no buffering is happening at the moment. + void markUnBuffered() { + if (_wouldBuffer) { + _wouldBuffer = false; + _controller.add(null); + } + } + + /// Signals that buffering starts to happen. + void markBuffered() { + _wouldBuffer = true; + } + + /// A broadcast stream notifying users that the [BufferedBytesWriter.add] + /// method would not buffer the data if called. + Stream get bufferEmptyEvents => _controller.stream; +} + +/// Contains a [StreamSink] and a [BufferIndicator] to indicate whether writes +/// to the sink would cause buffering. +/// +/// It uses the `pause signal` from the `sink.addStream()` as an indicator +/// whether the underlying stream cannot handle more data and would buffer. +class BufferedSink { + /// The indicator whether the underlying sink is buffering at the moment. + final bufferIndicator = BufferIndicator(); + + /// A intermediate [StreamController] used to catch pause signals and to + /// propagate the change via [bufferIndicator]. + final _controller = StreamController>(sync: true); + + /// A future which completes once the sink has been closed. + late final Future _doneFuture; + + BufferedSink(StreamSink> dataSink) { + bufferIndicator.markBuffered(); + + _controller + ..onListen = bufferIndicator.markUnBuffered + ..onPause = bufferIndicator.markBuffered + ..onResume = bufferIndicator.markUnBuffered + ..onCancel = () { + // TODO: We may want to propagate cancel events as errors. + // Currently `_doneFuture` will just complete normally if the sink + // cancelled. + }; + _doneFuture = + Future.wait([_controller.stream.pipe(dataSink), dataSink.done]); + } + + /// The underlying sink. + StreamSink> get sink => _controller; + + /// The future which will complete once this sink has been closed. + Future get doneFuture => _doneFuture; +} + +/// A small wrapper around [BufferedSink] which writes data in batches. +class BufferedBytesWriter { + /// A buffer which will be used for batching writes. + final BytesBuilder _builder = BytesBuilder(copy: false); + + /// The underlying [BufferedSink]. + final BufferedSink _bufferedSink; + + BufferedBytesWriter(StreamSink> outgoing) + : _bufferedSink = BufferedSink(outgoing); + + /// An indicator whether the underlying sink is buffering at the moment. + BufferIndicator get bufferIndicator => _bufferedSink.bufferIndicator; + + /// Adds [data] immediately to the underlying buffer. + /// + /// If there is buffered data which was added with [addBufferedData] and it + /// has not been flushed with [flushBufferedData] an error will be thrown. + void add(List data) { + if (_builder.length > 0) { + throw StateError( + 'Cannot trigger an asynchronous write while there is buffered data.'); + } + _bufferedSink.sink.add(data); + } + + /// Queues up [bytes] to be written. + void addBufferedData(List bytes) { + _builder.add(bytes); + } + + /// Flushes all data which was enqueued by [addBufferedData]. + void flushBufferedData() { + if (_builder.length > 0) { + _bufferedSink.sink.add(_builder.takeBytes()); + } + } + + /// Closes this sink. + Future close() { + flushBufferedData(); + return _bufferedSink.sink.close().whenComplete(() => doneFuture); + } + + /// The future which will complete once this sink has been closed. + Future get doneFuture => _bufferedSink.doneFuture; +} diff --git a/pkgs/http2/lib/src/byte_utils.dart b/pkgs/http2/lib/src/byte_utils.dart new file mode 100644 index 0000000000..671e339ea5 --- /dev/null +++ b/pkgs/http2/lib/src/byte_utils.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; + +List viewOrSublist(List data, int offset, int length) { + if (data is Uint8List) { + return Uint8List.view(data.buffer, data.offsetInBytes + offset, length); + } else { + return data.sublist(offset, offset + length); + } +} + +int readInt64(List bytes, int offset) { + var high = readInt32(bytes, offset); + var low = readInt32(bytes, offset + 4); + return high << 32 | low; +} + +int readInt32(List bytes, int offset) { + return (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; +} + +int readInt24(List bytes, int offset) { + return (bytes[offset] << 16) | (bytes[offset + 1] << 8) | bytes[offset + 2]; +} + +int readInt16(List bytes, int offset) { + return (bytes[offset] << 8) | bytes[offset + 1]; +} + +void setInt64(List bytes, int offset, int value) { + setInt32(bytes, offset, value >> 32); + setInt32(bytes, offset + 4, value & 0xffffffff); +} + +void setInt32(List bytes, int offset, int value) { + bytes[offset] = (value >> 24) & 0xff; + bytes[offset + 1] = (value >> 16) & 0xff; + bytes[offset + 2] = (value >> 8) & 0xff; + bytes[offset + 3] = value & 0xff; +} + +void setInt24(List bytes, int offset, int value) { + bytes[offset] = (value >> 16) & 0xff; + bytes[offset + 1] = (value >> 8) & 0xff; + bytes[offset + 2] = value & 0xff; +} + +void setInt16(List bytes, int offset, int value) { + bytes[offset] = (value >> 8) & 0xff; + bytes[offset + 1] = value & 0xff; +} diff --git a/pkgs/http2/lib/src/connection.dart b/pkgs/http2/lib/src/connection.dart new file mode 100644 index 0000000000..4e52e57f66 --- /dev/null +++ b/pkgs/http2/lib/src/connection.dart @@ -0,0 +1,509 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show utf8; + +import '../transport.dart'; +import 'connection_preface.dart'; +import 'flowcontrol/connection_queues.dart'; +import 'flowcontrol/queue_messages.dart'; +import 'flowcontrol/window.dart'; +import 'flowcontrol/window_handler.dart'; +import 'frames/frame_defragmenter.dart'; +import 'frames/frames.dart'; +import 'hpack/hpack.dart'; +import 'ping/ping_handler.dart'; +import 'settings/settings.dart'; +import 'streams/stream_handler.dart'; +import 'sync_errors.dart'; + +class ConnectionState { + /// The connection has been established, we're waiting for the settings frame + /// of the remote end. + static const int Initialized = 1; + + /// The connection has been established and is fully operational. + static const int Operational = 2; + + /// The connection is no longer accepting new streams or creating new streams. + static const int Finishing = 3; + + /// The connection has been terminated and cannot be used anymore. + static const int Terminated = 4; + + /// Whether we actively were finishing the connection. + static const int FinishingActive = 1; + + /// Whether we passively were finishing the connection. + static const int FinishingPassive = 2; + + int state = Initialized; + int finishingState = 0; + + ConnectionState(); + + bool get isInitialized => state == ConnectionState.Initialized; + + bool get isOperational => state == ConnectionState.Operational; + + bool get isFinishing => state == ConnectionState.Finishing; + + bool get isTerminated => state == ConnectionState.Terminated; + + bool get activeFinishing => + state == Finishing && (finishingState & FinishingActive) != 0; + + bool get passiveFinishing => + state == Finishing && (finishingState & FinishingPassive) != 0; + + @override + String toString() { + var message = ''; + + void add(bool condition, String flag) { + if (condition) { + if (message.isEmpty) { + message = flag; + } else { + message = '$message/$flag'; + } + } + } + + add(isInitialized, 'Initialized'); + add(isOperational, 'IsOperational'); + add(isFinishing, 'IsFinishing'); + add(isTerminated, 'IsTerminated'); + add(activeFinishing, 'ActiveFinishing'); + add(passiveFinishing, 'PassiveFinishing'); + + return message; + } +} + +abstract class Connection { + /// The settings the other end has acknowledged to use when communicating with + /// us. + final ActiveSettings acknowledgedSettings = ActiveSettings(); + + /// The settings we have to obey communicating with the other side. + final ActiveSettings peerSettings = ActiveSettings(); + + /// Whether this connection is a client connection. + final bool isClientConnection; + + /// Active state handler for this connection. + ActiveStateHandler? onActiveStateChanged; + + final Completer _onInitialPeerSettingsReceived = Completer(); + + final StreamController _pingReceived = StreamController(); + + final StreamController _frameReceived = StreamController(); + + /// Future which completes when the first SETTINGS frame is received from + /// the peer. + Future get onInitialPeerSettingsReceived => + _onInitialPeerSettingsReceived.future; + + /// The HPack context for this connection. + final HPackContext _hpackContext = HPackContext(); + + /// The flow window for this connection of the peer. + final Window _peerWindow = Window(); + + /// The flow window for this connection of this end. + final Window _localWindow = Window(); + + /// Used for defragmenting PushPromise/Header frames. + final FrameDefragmenter _defragmenter = FrameDefragmenter(); + + /// The outgoing frames of this connection; + late FrameWriter _frameWriter; + + /// A subscription of incoming [Frame]s. + late StreamSubscription _frameReaderSubscription; + + /// The incoming connection-level message queue. + late ConnectionMessageQueueIn _incomingQueue; + + /// The outgoing connection-level message queue. + late ConnectionMessageQueueOut _outgoingQueue; + + /// The ping handler used for making pings & handling remote pings. + late PingHandler _pingHandler; + + /// The settings handler used for changing settings & for handling remote + /// setting changes. + late SettingsHandler _settingsHandler; + + /// The set of active streams this connection has. + late StreamHandler _streams; + + /// The connection-level flow control window handler for outgoing messages. + late OutgoingConnectionWindowHandler _connectionWindowHandler; + + /// The state of this connection. + late ConnectionState _state; + + Connection(Stream> incoming, StreamSink> outgoing, + Settings settings, + {this.isClientConnection = true}) { + _setupConnection(incoming, outgoing, settings); + } + + /// Runs all setup necessary before new streams can be created with the remote + /// peer. + void _setupConnection(Stream> incoming, + StreamSink> outgoing, Settings settingsObject) { + // Setup frame reading. + var incomingFrames = + FrameReader(incoming, acknowledgedSettings).startDecoding(); + _frameReaderSubscription = incomingFrames.listen((Frame frame) { + _catchProtocolErrors(() => _handleFrameImpl(frame)); + }, onError: (error, stack) { + _terminate(ErrorCode.CONNECT_ERROR, causedByTransportError: true); + }, onDone: () { + // Ensure existing messages from lower levels are sent to the upper + // levels before we terminate everything. + _incomingQueue.forceDispatchIncomingMessages(); + _streams.forceDispatchIncomingMessages(); + + _terminate(ErrorCode.CONNECT_ERROR, causedByTransportError: true); + }); + + // Setup frame writing. + _frameWriter = FrameWriter(_hpackContext.encoder, outgoing, peerSettings); + _frameWriter.doneFuture.whenComplete(() { + _terminate(ErrorCode.CONNECT_ERROR, causedByTransportError: true); + }); + + // Setup handlers. + _settingsHandler = SettingsHandler(_hpackContext.encoder, _frameWriter, + acknowledgedSettings, peerSettings); + _pingHandler = PingHandler(_frameWriter, _pingReceived); + + var settings = _decodeSettings(settingsObject); + + // Do the initial settings handshake (possibly with pushes disabled). + _settingsHandler.changeSettings(settings).catchError((Object error) { + // TODO: The [error] can contain sensitive information we now expose via + // a [Goaway] frame. We should somehow ensure we're only sending useful + // but non-sensitive information. + _terminate(ErrorCode.PROTOCOL_ERROR, + message: 'Failed to set initial settings (error: $error).'); + }); + + _settingsHandler.onInitialWindowSizeChange.listen((int difference) { + _catchProtocolErrors(() { + _streams.processInitialWindowSizeSettingChange(difference); + }); + }); + + // Setup the connection window handler, which keeps track of the + // size of the outgoing connection window. + _connectionWindowHandler = OutgoingConnectionWindowHandler(_peerWindow); + + var connectionWindowUpdater = + IncomingWindowHandler.connection(_frameWriter, _localWindow); + + // Setup queues for outgoing/incoming messages on the connection level. + _outgoingQueue = + ConnectionMessageQueueOut(_connectionWindowHandler, _frameWriter); + _incomingQueue = + ConnectionMessageQueueIn(connectionWindowUpdater, _catchProtocolErrors); + + if (isClientConnection) { + _streams = StreamHandler.client( + _frameWriter, + _incomingQueue, + _outgoingQueue, + _settingsHandler.peerSettings, + _settingsHandler.acknowledgedSettings, + _activeStateHandler); + } else { + _streams = StreamHandler.server( + _frameWriter, + _incomingQueue, + _outgoingQueue, + _settingsHandler.peerSettings, + _settingsHandler.acknowledgedSettings, + _activeStateHandler); + } + + // NOTE: We're not waiting until initial settings have been exchanged + // before we start using the connection (i.e. we don't wait for half a + // round-trip-time). + _state = ConnectionState(); + } + + List _decodeSettings(Settings settings) { + var settingsList = []; + + // By default a endpoint can make an unlimited number of concurrent streams. + var concurrentStreamLimit = settings.concurrentStreamLimit; + if (concurrentStreamLimit != null) { + settingsList.add(Setting( + Setting.SETTINGS_MAX_CONCURRENT_STREAMS, concurrentStreamLimit)); + } + + // By default the stream level flow control window is 64 KiB. + var streamWindowSize = settings.streamWindowSize; + if (streamWindowSize != null) { + settingsList + .add(Setting(Setting.SETTINGS_INITIAL_WINDOW_SIZE, streamWindowSize)); + } + + if (settings is ClientSettings) { + // By default the server is allowed to do server pushes. + if (!settings.allowServerPushes) { + settingsList.add(Setting(Setting.SETTINGS_ENABLE_PUSH, 0)); + } + } else if (settings is ServerSettings) { + // No special server settings at the moment. + } else { + assert(false); + } + + return settingsList; + } + + /// Pings the remote peer (can e.g. be used for measuring latency). + Future ping() { + return _pingHandler.ping().catchError((e, s) { + return Future.error( + TransportException('The connection has been terminated.')); + }, test: (e) => e is TerminatedException); + } + + /// Finishes this connection. + Future finish() { + _finishing(active: true); + + // TODO: There is probably more we need to wait for. + return _streams.done.whenComplete(() => + Future.wait([_frameWriter.close(), _frameReaderSubscription.cancel()])); + } + + /// Terminates this connection forcefully. + Future terminate([int? errorCode]) { + return _terminate(errorCode ?? ErrorCode.NO_ERROR); + } + + void _activeStateHandler(bool isActive) => + onActiveStateChanged?.call(isActive); + + /// Invokes the passed in closure and catches any exceptions. + void _catchProtocolErrors(void Function() fn) { + try { + fn(); + } on ProtocolException catch (error) { + _terminate(ErrorCode.PROTOCOL_ERROR, message: '$error'); + } on FlowControlException catch (error) { + _terminate(ErrorCode.FLOW_CONTROL_ERROR, message: '$error'); + } on FrameSizeException catch (error) { + _terminate(ErrorCode.FRAME_SIZE_ERROR, message: '$error'); + } on HPackDecodingException catch (error) { + _terminate(ErrorCode.PROTOCOL_ERROR, message: '$error'); + } on TerminatedException { + // We tried to perform an action even though the connection was already + // terminated. + // TODO: Can this even happen and if so, how should we propagate this + // error? + } catch (error) { + _terminate(ErrorCode.INTERNAL_ERROR, message: '$error'); + } + } + + void _handleFrameImpl(Frame? frame) { + // The first frame from the other side must be a [SettingsFrame], otherwise + // we terminate the connection. + if (_state.isInitialized) { + if (frame is! SettingsFrame) { + _terminate(ErrorCode.PROTOCOL_ERROR, + message: 'Expected to first receive a settings frame.'); + return; + } + _state.state = ConnectionState.Operational; + _onInitialPeerSettingsReceived.complete(); + } + + // Try to defragment [frame] if it is a Headers/PushPromise frame. + frame = _defragmenter.tryDefragmentFrame(frame); + if (frame == null) return; + + // Try to decode headers if it's a Headers/PushPromise frame. + // [This needs to be done even if the frames get ignored, since the entire + // connection shares one HPack compression context.] + if (frame is HeadersFrame) { + frame.decodedHeaders = + _hpackContext.decoder.decode(frame.headerBlockFragment); + } else if (frame is PushPromiseFrame) { + frame.decodedHeaders = + _hpackContext.decoder.decode(frame.headerBlockFragment); + } + if (_frameReceived.hasListener) { + _frameReceived.add(null); + } + + // Handle the frame as either a connection or a stream frame. + if (frame.header.streamId == 0) { + if (frame is SettingsFrame) { + _settingsHandler.handleSettingsFrame(frame); + } else if (frame is PingFrame) { + _pingHandler.processPingFrame(frame); + } else if (frame is WindowUpdateFrame) { + _connectionWindowHandler.processWindowUpdate(frame); + } else if (frame is GoawayFrame) { + _streams.processGoawayFrame(frame); + _finishing(active: false); + } else if (frame is UnknownFrame) { + // We can safely ignore these. + } else { + throw ProtocolException( + 'Cannot handle frame type ${frame.runtimeType} with stream-id 0.'); + } + } else { + _streams.processStreamFrame(_state, frame); + } + } + + void _finishing({bool active = true, String? message}) { + // If this connection is already dead, we return. + if (_state.isTerminated) return; + + // If this connection is already finishing, we make sure to store the + // passive bit, since this information is used by [StreamHandler]. + // + // Vice versa should not matter: If we started passively finishing, an + // active finish should be a NOP. + if (_state.isFinishing) { + if (!active) _state.finishingState |= ConnectionState.FinishingPassive; + return; + } + + assert(_state.isInitialized || _state.isOperational); + + // If we are actively finishing this connection, we'll send a + // GoawayFrame otherwise we'll just propagate the message. + if (active) { + _state.state = ConnectionState.Finishing; + _state.finishingState |= ConnectionState.FinishingActive; + + _outgoingQueue.enqueueMessage(GoawayMessage( + _streams.highestPeerInitiatedStream, + ErrorCode.NO_ERROR, + message != null ? utf8.encode(message) : [])); + } else { + _state.state = ConnectionState.Finishing; + _state.finishingState |= ConnectionState.FinishingPassive; + } + + _streams.startClosing(); + } + + /// Terminates this connection (if it is not already terminated). + /// + /// The returned future will never complete with an error. + Future _terminate(int errorCode, + {bool causedByTransportError = false, String? message}) { + // TODO: When do we complete here? + if (_state.state != ConnectionState.Terminated) { + _state.state = ConnectionState.Terminated; + + var cancelFuture = Future.sync(_frameReaderSubscription.cancel); + if (!causedByTransportError) { + _outgoingQueue.enqueueMessage(GoawayMessage( + _streams.highestPeerInitiatedStream, + errorCode, + message != null ? utf8.encode(message) : [])); + } + var closeFuture = _frameWriter.close().catchError((e, s) { + // We ignore any errors after writing to [GoawayFrame] + }); + + // Close all lower level handlers with an error message. + // (e.g. if there is a pending connection.ping(), it's returned + // Future will complete with this error). + var exception = TransportConnectionException( + errorCode, 'Connection is being forcefully terminated.'); + + // Close all streams & stream queues + _streams.terminate(exception); + + // Close the connection queues + _incomingQueue.terminate(exception); + _outgoingQueue.terminate(exception); + + _pingHandler.terminate(exception); + _settingsHandler.terminate(exception); + + return Future.wait([cancelFuture, closeFuture]) + .catchError((_) => const []); + } + return Future.value(); + } +} + +class ClientConnection extends Connection implements ClientTransportConnection { + ClientConnection._(super.incoming, super.outgoing, super.settings) + : super(isClientConnection: true); + + factory ClientConnection(Stream> incoming, + StreamSink> outgoing, ClientSettings clientSettings) { + outgoing.add(CONNECTION_PREFACE); + return ClientConnection._(incoming, outgoing, clientSettings); + } + + @override + bool get isOpen => + !_state.isFinishing && !_state.isTerminated && _streams.canOpenStream; + + @override + ClientTransportStream makeRequest(List
headers, + {bool endStream = false}) { + if (_state.isFinishing) { + throw StateError( + 'The http/2 connection is finishing and can therefore not be used to ' + 'make new streams.'); + } else if (_state.isTerminated) { + throw StateError( + 'The http/2 connection is no longer active and can therefore not be ' + 'used to make new streams.'); + } + var hStream = _streams.newStream(headers, endStream: endStream); + if (_streams.ranOutOfStreamIds) { + _finishing(active: true, message: 'Ran out of stream ids'); + } + return hStream; + } + + @override + Stream get onPingReceived => _pingReceived.stream; + + @override + Stream get onFrameReceived => _frameReceived.stream; +} + +class ServerConnection extends Connection implements ServerTransportConnection { + ServerConnection._(super.incoming, super.outgoing, super.settings) + : super(isClientConnection: false); + + factory ServerConnection(Stream> incoming, + StreamSink> outgoing, ServerSettings serverSettings) { + var frameBytes = readConnectionPreface(incoming); + return ServerConnection._(frameBytes, outgoing, serverSettings); + } + + @override + Stream get incomingStreams => + _streams.incomingStreams.cast(); + + @override + Stream get onPingReceived => _pingReceived.stream; + + @override + Stream get onFrameReceived => _frameReceived.stream; +} diff --git a/pkgs/http2/lib/src/connection_preface.dart b/pkgs/http2/lib/src/connection_preface.dart new file mode 100644 index 0000000000..2f0b98c402 --- /dev/null +++ b/pkgs/http2/lib/src/connection_preface.dart @@ -0,0 +1,114 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'byte_utils.dart'; + +/// This is a set of bytes with which a client connection begins in the normal +/// case. It can be used on a server to distinguish HTTP/1.1 and HTTP/2 clients. +const List CONNECTION_PREFACE = [ + 0x50, + 0x52, + 0x49, + 0x20, + 0x2a, + 0x20, + 0x48, + 0x54, + 0x54, + 0x50, + 0x2f, + 0x32, + 0x2e, + 0x30, + 0x0d, + 0x0a, + 0x0d, + 0x0a, + 0x53, + 0x4d, + 0x0d, + 0x0a, + 0x0d, + 0x0a +]; + +/// Reads the connection preface from [incoming]. +/// +/// The returned `Stream` will be a duplicate of `incoming` without the +/// connection preface. If an error occurs while reading the connection +/// preface, the returned stream will have only an error. +Stream> readConnectionPreface(Stream> incoming) { + final result = StreamController>(); + late StreamSubscription subscription; + var connectionPrefaceRead = false; + var prefaceBuffer = []; + var terminated = false; + + void terminate(Object error) { + if (!terminated) { + result.addError(error); + result.close(); + subscription.cancel(); + } + terminated = true; + } + + bool compareConnectionPreface(List data) { + for (var i = 0; i < CONNECTION_PREFACE.length; i++) { + if (data[i] != CONNECTION_PREFACE[i]) { + terminate('Connection preface does not match.'); + return false; + } + } + connectionPrefaceRead = true; + return true; + } + + void onData(List data) { + if (connectionPrefaceRead) { + // Forward data after reading preface. + result.add(data); + } else { + if (prefaceBuffer.isEmpty && data.length > CONNECTION_PREFACE.length) { + if (!compareConnectionPreface(data)) return; + data = data.sublist(CONNECTION_PREFACE.length); + } else if (prefaceBuffer.length < CONNECTION_PREFACE.length) { + var remaining = CONNECTION_PREFACE.length - prefaceBuffer.length; + + var end = min(data.length, remaining); + var part1 = viewOrSublist(data, 0, end); + var part2 = viewOrSublist(data, end, data.length - end); + prefaceBuffer.addAll(part1); + + if (prefaceBuffer.length == CONNECTION_PREFACE.length) { + if (!compareConnectionPreface(prefaceBuffer)) return; + } + data = part2; + } + if (data.isNotEmpty) { + result.add(data); + } + } + } + + result.onListen = () { + subscription = + incoming.listen(onData, onError: result.addError, onDone: () { + if (!connectionPrefaceRead) { + terminate('EOS before connection preface could be read.'); + } else { + result.close(); + } + }); + result + ..onPause = subscription.pause + ..onResume = subscription.resume + ..onCancel = subscription.cancel; + }; + + return result.stream; +} diff --git a/pkgs/http2/lib/src/error_handler.dart b/pkgs/http2/lib/src/error_handler.dart new file mode 100644 index 0000000000..a8e19204ef --- /dev/null +++ b/pkgs/http2/lib/src/error_handler.dart @@ -0,0 +1,105 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'sync_errors.dart'; + +/// Used by classes which may be terminated from the outside. +mixin TerminatableMixin { + bool _terminated = false; + + /// Terminates this stream message queue. Further operations on it will fail. + void terminate([Object? error]) { + if (!wasTerminated) { + _terminated = true; + onTerminated(error); + } + } + + bool get wasTerminated => _terminated; + + void onTerminated(Object? error) { + // Subclasses can override this method if they want. + } + + T ensureNotTerminatedSync(T Function() f) { + if (wasTerminated) { + throw TerminatedException(); + } + return f(); + } + + Future ensureNotTerminatedAsync(Future Function() f) { + if (wasTerminated) { + return Future.error(TerminatedException()); + } + return f(); + } +} + +/// Used by classes which may be cancelled. +mixin CancellableMixin { + bool _cancelled = false; + final _cancelCompleter = Completer.sync(); + + Future get onCancel => _cancelCompleter.future; + + /// Cancel this stream message queue. Further operations on it will fail. + void cancel() { + if (!wasCancelled) { + _cancelled = true; + _cancelCompleter.complete(); + } + } + + bool get wasCancelled => _cancelled; +} + +/// Used by classes which may be closed. +mixin ClosableMixin { + bool _closing = false; + final Completer _completer = Completer(); + + Future get done => _completer.future; + + bool get isClosing => _closing; + bool get wasClosed => _completer.isCompleted; + + void startClosing() { + if (!_closing) { + _closing = true; + + onClosing(); + } + onCheckForClose(); + } + + void onCheckForClose() { + // Subclasses can override this method if they want. + } + + void onClosing() { + // Subclasses can override this method if they want. + } + + dynamic ensureNotClosingSync(dynamic Function() f) { + if (isClosing) { + throw StateError('Was in the process of closing.'); + } + return f(); + } + + void closeWithValue([Object? value]) { + if (!wasClosed) { + _completer.complete(value); + } + } + + void closeWithError(Object? error) { + if (!wasClosed) { + _completer.complete(error); + } + } +} diff --git a/pkgs/http2/lib/src/flowcontrol/connection_queues.dart b/pkgs/http2/lib/src/flowcontrol/connection_queues.dart new file mode 100644 index 0000000000..f1a499a903 --- /dev/null +++ b/pkgs/http2/lib/src/flowcontrol/connection_queues.dart @@ -0,0 +1,354 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// TODO: Take priorities into account. +// TODO: Properly fragment large data frames, so they are not taking up too much +// bandwidth. + +import 'dart:async'; +import 'dart:collection'; + +import '../../transport.dart'; +import '../byte_utils.dart'; +import '../error_handler.dart'; +import '../frames/frames.dart'; +import 'queue_messages.dart'; +import 'stream_queues.dart'; +import 'window_handler.dart'; + +/// The last place before messages coming from the application get encoded and +/// send as [Frame]s. +/// +/// It will convert [Message]s from higher layers and send them via [Frame]s. +/// +/// - It will queue messages until the connection-level flow control window +/// allows sending the message and the underlying [StreamSink] is not +/// buffering. +/// - It will use a [FrameWriter] to write a new frame to the connection. +// TODO: Make [StreamsHandler] call [connectionOut.startClosing()] once +// * all streams have been closed +// * the connection state is finishing +class ConnectionMessageQueueOut extends Object + with TerminatableMixin, ClosableMixin { + /// The handler which will be used for increasing the connection-level flow + /// control window. + final OutgoingConnectionWindowHandler _connectionWindow; + + /// The buffered [Message]s which are to be delivered to the remote peer. + final Queue _messages = Queue(); + + /// The [FrameWriter] used for writing Headers/Data/PushPromise frames. + final FrameWriter _frameWriter; + + ConnectionMessageQueueOut(this._connectionWindow, this._frameWriter) { + _frameWriter.bufferIndicator.bufferEmptyEvents.listen((_) { + _trySendMessages(); + }); + _connectionWindow.positiveWindow.bufferEmptyEvents.listen((_) { + _trySendMessages(); + }); + } + + /// The number of pending messages which haven't been written to the wire. + int get pendingMessages => _messages.length; + + /// Enqueues a new [Message] which should be delivered to the remote peer. + void enqueueMessage(Message message) { + ensureNotClosingSync(() { + if (!wasTerminated) { + _messages.addLast(message); + _trySendMessages(); + } + }); + } + + @override + void onTerminated(Object? error) { + _messages.clear(); + closeWithError(error); + } + + @override + void onCheckForClose() { + if (isClosing && _messages.isEmpty) { + closeWithValue(); + } + } + + void _trySendMessages() { + if (!wasTerminated) { + // We can make progress if + // * there is at least one message to send + // * the underlying frame writer / sink / socket doesn't block + // * either one + // * the next message is a non-flow control message (e.g. headers) + // * the connection window is positive + + if (_messages.isNotEmpty && + !_frameWriter.bufferIndicator.wouldBuffer && + (!_connectionWindow.positiveWindow.wouldBuffer || + _messages.first is! DataMessage)) { + _trySendMessage(); + + // If we have more messages and we can send them, we'll run them + // using `Timer.run()` to let other things get in-between. + if (_messages.isNotEmpty && + !_frameWriter.bufferIndicator.wouldBuffer && + (!_connectionWindow.positiveWindow.wouldBuffer || + _messages.first is! DataMessage)) { + // TODO: If all the frame writer methods would return the + // number of bytes written, we could just say, we loop here until 10kb + // and after words, we'll make `Timer.run()`. + Timer.run(_trySendMessages); + } else { + onCheckForClose(); + } + } + } + } + + void _trySendMessage() { + var message = _messages.first; + if (message is HeadersMessage) { + _messages.removeFirst(); + _frameWriter.writeHeadersFrame(message.streamId, message.headers, + endStream: message.endStream); + } else if (message is PushPromiseMessage) { + _messages.removeFirst(); + _frameWriter.writePushPromiseFrame( + message.streamId, message.promisedStreamId, message.headers); + } else if (message is DataMessage) { + _messages.removeFirst(); + + if (_connectionWindow.peerWindowSize >= message.bytes.length) { + _connectionWindow.decreaseWindow(message.bytes.length); + _frameWriter.writeDataFrame(message.streamId, message.bytes, + endStream: message.endStream); + } else { + // NOTE: We need to fragment the DataMessage. + // TODO: Do not fragment if the number of bytes we can send is too low + var len = _connectionWindow.peerWindowSize; + var head = viewOrSublist(message.bytes, 0, len); + var tail = + viewOrSublist(message.bytes, len, message.bytes.length - len); + + _connectionWindow.decreaseWindow(head.length); + _frameWriter.writeDataFrame(message.streamId, head, endStream: false); + + var tailMessage = + DataMessage(message.streamId, tail, message.endStream); + _messages.addFirst(tailMessage); + } + } else if (message is ResetStreamMessage) { + _messages.removeFirst(); + _frameWriter.writeRstStreamFrame(message.streamId, message.errorCode); + } else if (message is GoawayMessage) { + _messages.removeFirst(); + _frameWriter.writeGoawayFrame( + message.lastStreamId, message.errorCode, message.debugData); + } else { + throw StateError('Unexpected message in queue: ${message.runtimeType}'); + } + } +} + +/// The first place an incoming stream message gets delivered to. +/// +/// The [ConnectionMessageQueueIn] will be given [Frame]s which were sent to +/// any stream on this connection. +/// +/// - It will extract the necessary data from the [Frame] and store it in a new +/// [Message] object. +/// - It will multiplex the created [Message]es to a stream-specific +/// [StreamMessageQueueIn]. +/// - If the [StreamMessageQueueIn] cannot accept more data, the data will be +/// buffered until it can. +/// - [DataMessage]s which have been successfully delivered to a stream-specific +/// [StreamMessageQueueIn] will increase the flow control window for the +/// connection. +/// +/// Incoming [DataFrame]s will decrease the flow control window the peer has +/// available. +// TODO: Make [StreamsHandler] call [connectionOut.startClosing()] once +// * all streams have been closed +// * the connection state is finishing +class ConnectionMessageQueueIn extends Object + with TerminatableMixin, ClosableMixin { + /// The handler which will be used for increasing the connection-level flow + /// control window. + final IncomingWindowHandler _windowUpdateHandler; + + /// Catches any protocol errors and acts upon them. + final void Function(void Function()) _catchProtocolErrors; + + /// A mapping from stream-id to the corresponding stream-specific + /// [StreamMessageQueueIn]. + final Map _stream2messageQueue = {}; + + /// A buffer for [Message]s which cannot be received by their + /// [StreamMessageQueueIn]. + final Map> _stream2pendingMessages = {}; + + /// The number of pending messages which haven't been delivered + /// to the stream-specific queue. (for debugging purposes) + int _count = 0; + + ConnectionMessageQueueIn( + this._windowUpdateHandler, this._catchProtocolErrors); + + @override + void onTerminated(Object? error) { + // NOTE: The higher level will be shutdown first, so all streams + // should have been removed at this point. + assert(_stream2messageQueue.isEmpty); + assert(_stream2pendingMessages.isEmpty); + closeWithError(error); + } + + @override + void onCheckForClose() { + if (isClosing) { + assert(_stream2messageQueue.isEmpty == _stream2pendingMessages.isEmpty); + if (_stream2messageQueue.isEmpty) { + closeWithValue(); + } + } + } + + /// The number of pending messages which haven't been delivered + /// to the stream-specific queue. (for debugging purposes) + int get pendingMessages => _count; + + /// Registers a stream specific [StreamMessageQueueIn] for a new stream id. + void insertNewStreamMessageQueue(int streamId, StreamMessageQueueIn mq) { + if (_stream2messageQueue.containsKey(streamId)) { + throw ArgumentError( + 'Cannot register a SteramMessageQueueIn for the same streamId ' + 'multiple times'); + } + + var pendingMessages = Queue(); + _stream2pendingMessages[streamId] = pendingMessages; + _stream2messageQueue[streamId] = mq; + + mq.bufferIndicator.bufferEmptyEvents.listen((_) { + _catchProtocolErrors(() { + _tryDispatch(streamId, mq, pendingMessages); + }); + }); + } + + /// Removes a stream id and its message queue from this connection-level + /// message queue. + void removeStreamMessageQueue(int streamId) { + _stream2pendingMessages.remove(streamId); + _stream2messageQueue.remove(streamId); + } + + /// Processes an incoming [DataFrame] which is addressed to a specific stream. + void processDataFrame(DataFrame frame) { + var streamId = frame.header.streamId; + var message = DataMessage(streamId, frame.bytes, frame.hasEndStreamFlag); + + _windowUpdateHandler.gotData(message.bytes.length); + _addMessage(streamId, message); + } + + /// If a [DataFrame] will be ignored, this method will take the minimal + /// action necessary. + void processIgnoredDataFrame(DataFrame frame) { + _windowUpdateHandler.dataProcessed(frame.bytes.length); + } + + /// Processes an incoming [HeadersFrame] which is addressed to a specific + /// stream. + void processHeadersFrame(HeadersFrame frame) { + var streamId = frame.header.streamId; + var message = + HeadersMessage(streamId, frame.decodedHeaders, frame.hasEndStreamFlag); + // NOTE: Header frames do not affect flow control - only data frames do. + _addMessage(streamId, message); + } + + /// Processes an incoming [PushPromiseFrame] which is addressed to a specific + /// stream. + void processPushPromiseFrame( + PushPromiseFrame frame, ClientTransportStream pushedStream) { + var streamId = frame.header.streamId; + var message = PushPromiseMessage(streamId, frame.decodedHeaders, + frame.promisedStreamId, pushedStream, false); + + // NOTE: + // * Header frames do not affect flow control - only data frames do. + // * At this point we might reorder a push message earlier than + // data/headers messages. + _addPushMessage(streamId, message); + } + + void _addMessage(int streamId, Message message) { + _count++; + + // TODO: Do we need to do a runtime check here and + // raise a protocol error if we cannot find the registered stream? + var streamMQ = _stream2messageQueue[streamId]!; + var pendingMessages = _stream2pendingMessages[streamId]!; + pendingMessages.addLast(message); + _tryDispatch(streamId, streamMQ, pendingMessages); + } + + void _addPushMessage(int streamId, PushPromiseMessage message) { + _count++; + + // TODO: Do we need to do a runtime check here and + // raise a protocol error if we cannot find the registered stream? + var streamMQ = _stream2messageQueue[streamId]!; + streamMQ.enqueueMessage(message); + } + + void _tryDispatch( + int streamId, StreamMessageQueueIn mq, Queue pendingMessages) { + var bytesDeliveredToStream = 0; + while (!mq.bufferIndicator.wouldBuffer && pendingMessages.isNotEmpty) { + _count--; + + var message = pendingMessages.removeFirst(); + if (message is DataMessage) { + bytesDeliveredToStream += message.bytes.length; + } + mq.enqueueMessage(message); + if (message.endStream) { + assert(pendingMessages.isEmpty); + + _stream2messageQueue.remove(streamId); + _stream2pendingMessages.remove(streamId); + } + } + if (bytesDeliveredToStream > 0) { + _windowUpdateHandler.dataProcessed(bytesDeliveredToStream); + } + + onCheckForClose(); + } + + void forceDispatchIncomingMessages() { + final toBeRemoved = {}; + _stream2pendingMessages.forEach((int streamId, Queue messages) { + final mq = _stream2messageQueue[streamId]!; + while (messages.isNotEmpty) { + _count--; + final message = messages.removeFirst(); + mq.enqueueMessage(message); + if (message.endStream) { + toBeRemoved.add(streamId); + break; + } + } + }); + + for (final streamId in toBeRemoved) { + _stream2messageQueue.remove(streamId); + _stream2pendingMessages.remove(streamId); + } + } +} diff --git a/pkgs/http2/lib/src/flowcontrol/queue_messages.dart b/pkgs/http2/lib/src/flowcontrol/queue_messages.dart new file mode 100644 index 0000000000..5c71228d45 --- /dev/null +++ b/pkgs/http2/lib/src/flowcontrol/queue_messages.dart @@ -0,0 +1,75 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../transport.dart'; + +/// The subclasses of [Message] are objects that are coming from the +/// connection layer on top of frames. +/// +/// Messages on a HTTP/2 stream will be represented by a different class +/// hierarchy. +abstract class Message { + final int streamId; + final bool endStream; + + Message(this.streamId, this.endStream); +} + +class HeadersMessage extends Message { + final List
headers; + + HeadersMessage(int streamId, this.headers, bool endStream) + : super(streamId, endStream); + + @override + String toString() => + 'HeadersMessage(headers: ${headers.length}, endStream: $endStream)'; +} + +class DataMessage extends Message { + final List bytes; + + DataMessage(int streamId, this.bytes, bool endStream) + : super(streamId, endStream); + + @override + String toString() => + 'DataMessage(bytes: ${bytes.length}, endStream: $endStream)'; +} + +class PushPromiseMessage extends Message { + final List
headers; + final int promisedStreamId; + final ClientTransportStream pushedStream; + + PushPromiseMessage(int streamId, this.headers, this.promisedStreamId, + this.pushedStream, bool endStream) + : super(streamId, endStream); + + @override + String toString() => 'PushPromiseMessage(bytes: ${headers.length}, ' + 'promisedStreamId: $promisedStreamId, endStream: $endStream)'; +} + +class ResetStreamMessage extends Message { + final int errorCode; + + ResetStreamMessage(int streamId, this.errorCode) : super(streamId, false); + + @override + String toString() => 'ResetStreamMessage(errorCode: $errorCode)'; +} + +class GoawayMessage extends Message { + final int lastStreamId; + final int errorCode; + final List debugData; + + GoawayMessage(this.lastStreamId, this.errorCode, this.debugData) + : super(0, false); + + @override + String toString() => 'GoawayMessage(lastStreamId: $lastStreamId, ' + 'errorCode: $errorCode, debugData: ${debugData.length})'; +} diff --git a/pkgs/http2/lib/src/flowcontrol/stream_queues.dart b/pkgs/http2/lib/src/flowcontrol/stream_queues.dart new file mode 100644 index 0000000000..de7950a66e --- /dev/null +++ b/pkgs/http2/lib/src/flowcontrol/stream_queues.dart @@ -0,0 +1,328 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +import '../../transport.dart'; +import '../async_utils/async_utils.dart'; +import '../byte_utils.dart'; +import '../error_handler.dart'; + +import 'connection_queues.dart'; +import 'queue_messages.dart'; +import 'window_handler.dart'; + +/// This class will buffer any headers/data messages in the order they were +/// added. +/// +/// It will ensure that we never send more data than the remote flow control +/// window allows. +class StreamMessageQueueOut extends Object + with TerminatableMixin, ClosableMixin { + /// The id of the stream this message queue belongs to. + final int streamId; + + /// The stream-level flow control handler. + final OutgoingStreamWindowHandler streamWindow; + + /// The underlying connection-level message queue. + final ConnectionMessageQueueOut connectionMessageQueue; + + /// A indicator for whether this queue is currently buffering. + final BufferIndicator bufferIndicator = BufferIndicator(); + + /// Buffered [Message]s which will be written to the underlying connection + /// if the flow control window allows so. + final Queue _messages = Queue(); + + /// Debugging data on how much data should be written to the underlying + /// connection message queue. + int toBeWrittenBytes = 0; + + /// Debugging data on how much data was written to the underlying connection + /// message queue. + int writtenBytes = 0; + + StreamMessageQueueOut( + this.streamId, this.streamWindow, this.connectionMessageQueue) { + streamWindow.positiveWindow.bufferEmptyEvents.listen((_) { + if (!wasTerminated) { + _trySendData(); + } + }); + if (streamWindow.positiveWindow.wouldBuffer) { + bufferIndicator.markBuffered(); + } else { + bufferIndicator.markUnBuffered(); + } + } + + /// Debugging data about how many messages are pending to be written to the + /// connection message queue. + int get pendingMessages => _messages.length; + + /// Enqueues a new [Message] which is to be delivered to the connection + /// message queue. + void enqueueMessage(Message message) { + if (message is! ResetStreamMessage) ensureNotClosingSync(() {}); + if (!wasTerminated) { + if (message.endStream) startClosing(); + + if (message is DataMessage) { + toBeWrittenBytes += message.bytes.length; + } + + _messages.addLast(message); + _trySendData(); + + if (_messages.isNotEmpty) { + bufferIndicator.markBuffered(); + } + } + } + + @override + void onTerminated(Object? error) { + _messages.clear(); + closeWithError(error); + } + + @override + void onCheckForClose() { + if (isClosing && _messages.isEmpty) closeWithValue(); + } + + void _trySendData() { + var queueLenBefore = _messages.length; + + while (_messages.isNotEmpty) { + var message = _messages.first; + + if (message is HeadersMessage) { + _messages.removeFirst(); + connectionMessageQueue.enqueueMessage(message); + } else if (message is DataMessage) { + var bytesAvailable = streamWindow.peerWindowSize; + if (bytesAvailable > 0 || message.bytes.isEmpty) { + _messages.removeFirst(); + + // Do we need to fragment? + var messageToSend = message; + var messageBytes = message.bytes; + // TODO: Do not fragment if the number of bytes we can send is too low + if (messageBytes.length > bytesAvailable) { + var partA = viewOrSublist(messageBytes, 0, bytesAvailable); + var partB = viewOrSublist(messageBytes, bytesAvailable, + messageBytes.length - bytesAvailable); + var messageA = DataMessage(message.streamId, partA, false); + var messageB = + DataMessage(message.streamId, partB, message.endStream); + + // Put the second fragment back into the front of the queue. + _messages.addFirst(messageB); + + // Send the first fragment. + messageToSend = messageA; + } + + writtenBytes += messageToSend.bytes.length; + streamWindow.decreaseWindow(messageToSend.bytes.length); + connectionMessageQueue.enqueueMessage(messageToSend); + } else { + break; + } + } else if (message is ResetStreamMessage) { + _messages.removeFirst(); + connectionMessageQueue.enqueueMessage(message); + } else { + throw StateError('Unknown messages type: ${message.runtimeType}'); + } + } + if (queueLenBefore > 0 && _messages.isEmpty) { + bufferIndicator.markUnBuffered(); + } + + onCheckForClose(); + } +} + +/// Keeps a list of [Message] which should be delivered to the +/// [TransportStream]. +/// +/// It will keep messages up to the stream flow control window size if the +/// [messages] listener is paused. +class StreamMessageQueueIn extends Object + with TerminatableMixin, ClosableMixin, CancellableMixin { + /// The stream-level window our peer is using when sending us messages. + final IncomingWindowHandler windowHandler; + + /// A indicator whether this [StreamMessageQueueIn] is currently buffering. + final BufferIndicator bufferIndicator = BufferIndicator(); + + /// The pending [Message]s which are to be delivered via the [messages] + /// stream. + final Queue _pendingMessages = Queue(); + + /// The [StreamController] used for producing the [messages] stream. + final _incomingMessagesC = StreamController(); + + /// The [StreamController] used for producing the [serverPushes] stream. + final _serverPushStreamsC = StreamController(); + + StreamMessageQueueIn(this.windowHandler) { + // We start by marking it as buffered, since no one is listening yet and + // incoming messages will get buffered. + bufferIndicator.markBuffered(); + + _incomingMessagesC + ..onListen = () { + if (!wasClosed && !wasTerminated) { + _tryDispatch(); + _tryUpdateBufferIndicator(); + } + } + ..onPause = _tryUpdateBufferIndicator + ..onResume = () { + if (!wasClosed && !wasTerminated) { + _tryDispatch(); + _tryUpdateBufferIndicator(); + } + } + ..onCancel = cancel; + + _serverPushStreamsC.onListen = () { + if (!wasClosed && !wasTerminated) { + _tryDispatch(); + _tryUpdateBufferIndicator(); + } + }; + } + + /// Debugging data: the number of pending messages in this queue. + int get pendingMessages => _pendingMessages.length; + + /// The stream of [StreamMessage]s which come from the remote peer. + Stream get messages => _incomingMessagesC.stream; + + /// The stream of [TransportStreamPush]es which come from the remote peer. + Stream get serverPushes => _serverPushStreamsC.stream; + + /// A lower layer enqueues a new [Message] which should be delivered to the + /// app. + void enqueueMessage(Message message) { + ensureNotClosingSync(() { + if (!wasTerminated) { + if (message is PushPromiseMessage) { + // NOTE: If server pushes were enabled, the client is responsible for + // either rejecting or handling them. + assert(!_serverPushStreamsC.isClosed); + var transportStreamPush = + TransportStreamPush(message.headers, message.pushedStream); + _serverPushStreamsC.add(transportStreamPush); + return; + } + + if (message is DataMessage) { + windowHandler.gotData(message.bytes.length); + } + _pendingMessages.add(message); + if (message.endStream) startClosing(); + + _tryDispatch(); + _tryUpdateBufferIndicator(); + } + }); + } + + @override + void onTerminated(Object? error) { + _pendingMessages.clear(); + if (!wasClosed) { + if (error != null) { + _incomingMessagesC.addError(error); + } + _incomingMessagesC.close(); + _serverPushStreamsC.close(); + closeWithError(error); + } + } + + void onCloseCheck() { + if (isClosing && !wasClosed && _pendingMessages.isEmpty) { + _incomingMessagesC.close(); + _serverPushStreamsC.close(); + closeWithValue(); + } + } + + void forceDispatchIncomingMessages() { + while (_pendingMessages.isNotEmpty) { + final message = _pendingMessages.removeFirst(); + assert(!_incomingMessagesC.isClosed); + if (message is HeadersMessage) { + _incomingMessagesC.add(HeadersStreamMessage(message.headers, + endStream: message.endStream)); + } else if (message is DataMessage) { + if (message.bytes.isNotEmpty) { + _incomingMessagesC.add( + DataStreamMessage(message.bytes, endStream: message.endStream)); + } + } else { + // This can never happen. + assert(false); + } + if (message.endStream) { + onCloseCheck(); + } + } + } + + void _tryDispatch() { + while (!wasTerminated && _pendingMessages.isNotEmpty) { + var handled = wasCancelled; + + var message = _pendingMessages.first; + if (wasCancelled) { + _pendingMessages.removeFirst(); + } else if (message is HeadersMessage || message is DataMessage) { + assert(!_incomingMessagesC.isClosed); + if (_incomingMessagesC.hasListener && !_incomingMessagesC.isPaused) { + _pendingMessages.removeFirst(); + if (message is HeadersMessage) { + // NOTE: Header messages do not affect flow control - only + // data messages do. + _incomingMessagesC.add(HeadersStreamMessage(message.headers, + endStream: message.endStream)); + } else if (message is DataMessage) { + if (message.bytes.isNotEmpty) { + _incomingMessagesC.add(DataStreamMessage(message.bytes, + endStream: message.endStream)); + windowHandler.dataProcessed(message.bytes.length); + } + } else { + // This can never happen. + assert(false); + } + handled = true; + } + } + if (handled) { + if (message.endStream) { + onCloseCheck(); + } + } else { + break; + } + } + } + + void _tryUpdateBufferIndicator() { + if (_incomingMessagesC.isPaused || _pendingMessages.isNotEmpty) { + bufferIndicator.markBuffered(); + } else if (bufferIndicator.wouldBuffer && !_incomingMessagesC.isPaused) { + bufferIndicator.markUnBuffered(); + } + } +} diff --git a/pkgs/http2/lib/src/flowcontrol/window.dart b/pkgs/http2/lib/src/flowcontrol/window.dart new file mode 100644 index 0000000000..51b90161c0 --- /dev/null +++ b/pkgs/http2/lib/src/flowcontrol/window.dart @@ -0,0 +1,24 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +class Window { + static const int MAX_WINDOW_SIZE = (1 << 31) - 1; + + /// The size available in this window. + /// + /// The default flow control window for the entire connection and for new + /// streams is 65535). + /// + /// NOTE: This value can potentially become negative. + int _size; + + Window({int initialSize = (1 << 16) - 1}) : _size = initialSize; + + /// The current size of the flow control window. + int get size => _size; + + void modify(int difference) { + _size += difference; + } +} diff --git a/pkgs/http2/lib/src/flowcontrol/window_handler.dart b/pkgs/http2/lib/src/flowcontrol/window_handler.dart new file mode 100644 index 0000000000..27d0321694 --- /dev/null +++ b/pkgs/http2/lib/src/flowcontrol/window_handler.dart @@ -0,0 +1,162 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../async_utils/async_utils.dart'; +import '../frames/frames.dart'; +import '../sync_errors.dart'; + +import 'window.dart'; + +abstract class AbstractOutgoingWindowHandler { + /// The connection flow control window. + final Window _peerWindow; + + /// Indicates when the outgoing connection window turned positive and we can + /// send data frames again. + final BufferIndicator positiveWindow = BufferIndicator(); + + AbstractOutgoingWindowHandler(this._peerWindow) { + if (_peerWindow.size > 0) { + positiveWindow.markUnBuffered(); + } + } + + /// The flow control window size we use for sending data. We are not allowed + /// to let this window be negative. + int get peerWindowSize => _peerWindow.size; + + /// Process a window update frame received from the remote end. + void processWindowUpdate(WindowUpdateFrame frame) { + var increment = frame.windowSizeIncrement; + if ((_peerWindow.size + increment) > Window.MAX_WINDOW_SIZE) { + throw FlowControlException( + 'Window update received from remote peer would make flow control ' + 'window too large.'); + } else { + _peerWindow.modify(increment); + } + + // If we transitioned from an negative/empty window to a positive window + // we'll fire an event that more data frames can be sent now. + if (positiveWindow.wouldBuffer && _peerWindow.size > 0) { + positiveWindow.markUnBuffered(); + } + } + + /// Update the peer window by subtracting [numberOfBytes]. + /// + /// The remote peer will send us [WindowUpdateFrame]s which will increase + /// the window again at a later point in time. + void decreaseWindow(int numberOfBytes) { + _peerWindow.modify(-numberOfBytes); + if (_peerWindow.size <= 0) { + positiveWindow.markBuffered(); + } + } +} + +/// Handles the connection window for outgoing data frames. +class OutgoingConnectionWindowHandler extends AbstractOutgoingWindowHandler { + OutgoingConnectionWindowHandler(super.window); +} + +/// Handles the window for outgoing messages to the peer. +class OutgoingStreamWindowHandler extends AbstractOutgoingWindowHandler { + OutgoingStreamWindowHandler(super.window); + + /// Update the peer window by adding [difference] to it. + /// + /// + /// The remote peer has send a new [SettingsFrame] which updated the default + /// stream level [Setting.SETTINGS_INITIAL_WINDOW_SIZE]. This causes all + /// existing streams to update the flow stream-level flow control window. + void processInitialWindowSizeSettingChange(int difference) { + if ((_peerWindow.size + difference) > Window.MAX_WINDOW_SIZE) { + throw FlowControlException( + 'Window update received from remote peer would make flow control ' + 'window too large.'); + } else { + _peerWindow.modify(difference); + if (_peerWindow.size <= 0) { + positiveWindow.markBuffered(); + } else if (positiveWindow.wouldBuffer) { + positiveWindow.markUnBuffered(); + } + } + } +} + +/// Mirrors the flow control window the remote end is using. +class IncomingWindowHandler { + /// The [FrameWriter] used for writing [WindowUpdateFrame]s to the wire. + final FrameWriter _frameWriter; + + /// The mirror of the [Window] the remote end sees. + /// + /// If [_localWindow ] turns negative, it means the remote peer sent us more + /// data than we allowed it to send. + final Window _localWindow; + + /// The stream id this window handler is for (is `0` for connection level). + final int _streamId; + + IncomingWindowHandler.stream( + this._frameWriter, this._localWindow, this._streamId); + + IncomingWindowHandler.connection(this._frameWriter, this._localWindow) + : _streamId = 0; + + /// The current size for the incoming data window. + /// + /// (This should never get negative, otherwise the peer send us more data + /// than we told it to send.) + int get localWindowSize => _localWindow.size; + + /// Signals that we received [numberOfBytes] from the remote peer. + void gotData(int numberOfBytes) { + _localWindow.modify(-numberOfBytes); + + // If this turns negative, it means the remote end send us more data + // then we announced we can handle (i.e. the remote window size must be + // negative). + // + // NOTE: [_localWindow.size] tracks the amount of data we advertised that we + // can handle. The value can change in three situations: + // + // a) We received data from the remote end (we can handle now less data) + // => This is handled by [gotData]. + // + // b) We processed data from the remote end (we can handle now more data) + // => This is handled by [dataProcessed]. + // + // c) We increase/decrease the initial stream window size after the + // stream was created (newer streams will start with the changed + // initial stream window size). + // => This is not an issue, because we don't support changing the + // initial window size later on -- only during the initial + // settings exchange. Since streams (and therefore instances + // of [IncomingWindowHandler]) are only created after sending out + // our initial settings. + // + if (_localWindow.size < 0) { + throw FlowControlException( + 'Connection level flow control window became negative.'); + } + } + + /// Tell the peer we received [numberOfBytes] bytes. It will increase it's + /// sending window then. + /// + // TODO/FIXME: If we pause and don't want to get more data, we have to + // - either stop sending window update frames + // - or decreasing the window size + void dataProcessed(int numberOfBytes) { + _localWindow.modify(numberOfBytes); + + // TODO: This can be optimized by delaying the window update to + // send one update with a bigger difference than multiple small update + // frames. + _frameWriter.writeWindowUpdate(numberOfBytes, streamId: _streamId); + } +} diff --git a/pkgs/http2/lib/src/frames/frame_defragmenter.dart b/pkgs/http2/lib/src/frames/frame_defragmenter.dart new file mode 100644 index 0000000000..5ab2c72426 --- /dev/null +++ b/pkgs/http2/lib/src/frames/frame_defragmenter.dart @@ -0,0 +1,91 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../sync_errors.dart'; + +import 'frames.dart'; + +/// Class used for defragmenting [HeadersFrame]s and [PushPromiseFrame]s. +// TODO: Somehow emit an error if too many continuation frames have been sent +// (since we're buffering all of them). +class FrameDefragmenter { + /// The current incomplete [HeadersFrame] fragment. + HeadersFrame? _headersFrame; + + /// The current incomplete [PushPromiseFrame] fragment. + PushPromiseFrame? _pushPromiseFrame; + + /// Tries to defragment [frame]. + /// + /// If the given [frame] is a [HeadersFrame] or a [PushPromiseFrame] which + /// needs de-fragmentation, it will be saved and `null` will be returned. + /// + /// If there is currently an incomplete [HeadersFrame] or [PushPromiseFrame] + /// saved, [frame] needs to be a [ContinuationFrame]. It will be added to the + /// saved frame. In case the defragmentation is complete, the defragmented + /// [HeadersFrame] or [PushPromiseFrame] will be returned. + /// + /// All other [Frame] types will be returned. + // TODO: Consider handling continuation frames without preceding + // headers/push-promise frame here instead of the call site? + Frame? tryDefragmentFrame(Frame? frame) { + if (_headersFrame != null) { + if (frame is ContinuationFrame) { + if (_headersFrame!.header.streamId != frame.header.streamId) { + throw ProtocolException( + 'Defragmentation: frames have different stream ids.'); + } + _headersFrame = _headersFrame!.addBlockContinuation(frame); + + if (frame.hasEndHeadersFlag) { + var frame = _headersFrame; + _headersFrame = null; + return frame; + } else { + return null; + } + } else { + throw ProtocolException( + 'Defragmentation: Incomplete frame must be followed by ' + 'continuation frame.'); + } + } else if (_pushPromiseFrame != null) { + if (frame is ContinuationFrame) { + if (_pushPromiseFrame!.header.streamId != frame.header.streamId) { + throw ProtocolException( + 'Defragmentation: frames have different stream ids.'); + } + _pushPromiseFrame = _pushPromiseFrame!.addBlockContinuation(frame); + + if (frame.hasEndHeadersFlag) { + var frame = _pushPromiseFrame; + _pushPromiseFrame = null; + return frame; + } else { + return null; + } + } else { + throw ProtocolException( + 'Defragmentation: Incomplete frame must be followed by ' + 'continuation frame.'); + } + } else { + if (frame is HeadersFrame) { + if (!frame.hasEndHeadersFlag) { + _headersFrame = frame; + return null; + } + } else if (frame is PushPromiseFrame) { + if (!frame.hasEndHeadersFlag) { + _pushPromiseFrame = frame; + return null; + } + } + } + + // If this frame is not relevant for header defragmentation, we pass it to + // the next stage. + return frame; + } +} diff --git a/pkgs/http2/lib/src/frames/frame_reader.dart b/pkgs/http2/lib/src/frames/frame_reader.dart new file mode 100644 index 0000000000..7b692614aa --- /dev/null +++ b/pkgs/http2/lib/src/frames/frame_reader.dart @@ -0,0 +1,284 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of 'frames.dart'; + +/// Used for converting a `Stream>` to a `Stream`. +class FrameReader { + final Stream> _inputStream; + + /// Connection settings which this reader needs to ensure the remote end is + /// complying with. + final ActiveSettings _localSettings; + + final _framesController = StreamController(); + + FrameReader(this._inputStream, this._localSettings); + + /// Starts to listen on the input stream and decodes HTTP/2 transport frames. + Stream startDecoding() { + var bufferedData = >[]; + var bufferedLength = 0; + + FrameHeader? tryReadHeader() { + if (bufferedLength >= FRAME_HEADER_SIZE) { + // Get at least FRAME_HEADER_SIZE bytes in the first byte array. + _mergeLists(bufferedData, FRAME_HEADER_SIZE); + + // Read the frame header from the first byte array. + return _readFrameHeader(bufferedData[0], 0); + } + return null; + } + + Frame? tryReadFrame(FrameHeader header) { + var totalFrameLen = FRAME_HEADER_SIZE + header.length; + if (bufferedLength >= totalFrameLen) { + // Get the whole frame in the first byte array. + _mergeLists(bufferedData, totalFrameLen); + + // Read the frame. + var frame = _readFrame(header, bufferedData[0], FRAME_HEADER_SIZE); + + // Update bufferedData/bufferedLength + var firstChunkLen = bufferedData[0].length; + if (firstChunkLen == totalFrameLen) { + bufferedData.removeAt(0); + } else { + bufferedData[0] = viewOrSublist( + bufferedData[0], totalFrameLen, firstChunkLen - totalFrameLen); + } + bufferedLength -= totalFrameLen; + + return frame; + } + return null; + } + + _framesController.onListen = () { + FrameHeader? header; + + late StreamSubscription> subscription; + + void terminateWithError(Object error, [StackTrace? stack]) { + header = null; + _framesController.addError(error, stack); + subscription.cancel(); + _framesController.close(); + } + + subscription = _inputStream.listen((List data) { + bufferedData.add(data); + bufferedLength += data.length; + + try { + while (true) { + header ??= tryReadHeader(); + if (header != null) { + if (header!.length > _localSettings.maxFrameSize) { + terminateWithError( + FrameSizeException('Incoming frame is too big.')); + return; + } + + var frame = tryReadFrame(header!); + + if (frame != null) { + _framesController.add(frame); + header = null; + } else { + break; + } + } else { + break; + } + } + } catch (error, stack) { + terminateWithError(error, stack); + } + }, onError: (Object error, StackTrace stack) { + terminateWithError(error, stack); + }, onDone: () { + if (bufferedLength == 0) { + _framesController.close(); + } else { + terminateWithError(FrameSizeException( + 'Incoming byte stream ended with incomplete frame')); + } + }); + + _framesController + ..onPause = subscription.pause + ..onResume = subscription.resume; + }; + + return _framesController.stream; + } + + /// Combine combines/merges `List`s of `bufferedData` until + /// `numberOfBytes` have been accumulated. + /// + /// After calling `mergeLists`, `bufferedData[0]` will contain at least + /// `numberOfBytes` bytes. + void _mergeLists(List> bufferedData, int numberOfBytes) { + if (bufferedData[0].length < numberOfBytes) { + var numLists = 0; + var accumulatedLength = 0; + while (accumulatedLength < numberOfBytes && + numLists <= bufferedData.length) { + accumulatedLength += bufferedData[numLists++].length; + } + assert(accumulatedLength >= numberOfBytes); + var newList = Uint8List(accumulatedLength); + var offset = 0; + for (var i = 0; i < numLists; i++) { + var data = bufferedData[i]; + newList.setRange(offset, offset + data.length, data); + offset += data.length; + } + bufferedData[0] = newList; + bufferedData.removeRange(1, numLists); + } + } + + /// Reads a FrameHeader] from [bytes], starting at [offset]. + FrameHeader _readFrameHeader(List bytes, int offset) { + var length = readInt24(bytes, offset); + var type = bytes[offset + 3]; + var flags = bytes[offset + 4]; + var streamId = readInt32(bytes, offset + 5) & 0x7fffffff; + + return FrameHeader(length, type, flags, streamId); + } + + /// Reads a [Frame] from [bytes], starting at [frameOffset]. + Frame _readFrame(FrameHeader header, List bytes, int frameOffset) { + var frameEnd = frameOffset + header.length; + + var offset = frameOffset; + switch (header.type) { + case FrameType.DATA: + var padLength = 0; + if (_isFlagSet(header.flags, DataFrame.FLAG_PADDED)) { + _checkFrameLengthCondition((frameEnd - offset) >= 1); + padLength = bytes[offset++]; + } + var dataLen = frameEnd - offset - padLength; + _checkFrameLengthCondition(dataLen >= 0); + var dataBytes = viewOrSublist(bytes, offset, dataLen); + return DataFrame(header, padLength, dataBytes); + + case FrameType.HEADERS: + var padLength = 0; + if (_isFlagSet(header.flags, HeadersFrame.FLAG_PADDED)) { + _checkFrameLengthCondition((frameEnd - offset) >= 1); + padLength = bytes[offset++]; + } + int? streamDependency; + var exclusiveDependency = false; + int? weight; + if (_isFlagSet(header.flags, HeadersFrame.FLAG_PRIORITY)) { + _checkFrameLengthCondition((frameEnd - offset) >= 5); + exclusiveDependency = (bytes[offset] & 0x80) == 0x80; + streamDependency = readInt32(bytes, offset) & 0x7fffffff; + offset += 4; + weight = bytes[offset++]; + } + var headerBlockLen = frameEnd - offset - padLength; + _checkFrameLengthCondition(headerBlockLen >= 0); + var headerBlockFragment = viewOrSublist(bytes, offset, headerBlockLen); + return HeadersFrame(header, padLength, exclusiveDependency, + streamDependency, weight, headerBlockFragment); + + case FrameType.PRIORITY: + _checkFrameLengthCondition( + (frameEnd - offset) == PriorityFrame.FIXED_FRAME_LENGTH, + message: 'Priority frame length must be exactly 5 bytes.'); + var exclusiveDependency = (bytes[offset] & 0x80) == 0x80; + var streamDependency = readInt32(bytes, offset) & 0x7fffffff; + var weight = bytes[offset + 4]; + return PriorityFrame( + header, exclusiveDependency, streamDependency, weight); + + case FrameType.RST_STREAM: + _checkFrameLengthCondition( + (frameEnd - offset) == RstStreamFrame.FIXED_FRAME_LENGTH, + message: 'Rst frames must have a length of 4.'); + var errorCode = readInt32(bytes, offset); + return RstStreamFrame(header, errorCode); + + case FrameType.SETTINGS: + _checkFrameLengthCondition((header.length % 6) == 0, + message: 'Settings frame length must be a multiple of 6 bytes.'); + + var count = header.length ~/ 6; + var settings = []; + for (var i = 0; i < count; i++) { + var identifier = readInt16(bytes, offset + 6 * i); + var value = readInt32(bytes, offset + 6 * i + 2); + settings.add(Setting(identifier, value)); + } + var frame = SettingsFrame(header, settings); + if (frame.hasAckFlag) { + _checkFrameLengthCondition(header.length == 0, + message: 'Settings frame length must 0 for ACKs.'); + } + return frame; + + case FrameType.PUSH_PROMISE: + var padLength = 0; + if (_isFlagSet(header.flags, PushPromiseFrame.FLAG_PADDED)) { + _checkFrameLengthCondition((frameEnd - offset) >= 1); + padLength = bytes[offset++]; + } + var promisedStreamId = readInt32(bytes, offset) & 0x7fffffff; + offset += 4; + var headerBlockLen = frameEnd - offset - padLength; + _checkFrameLengthCondition(headerBlockLen >= 0); + var headerBlockFragment = viewOrSublist(bytes, offset, headerBlockLen); + return PushPromiseFrame( + header, padLength, promisedStreamId, headerBlockFragment); + + case FrameType.PING: + _checkFrameLengthCondition( + (frameEnd - offset) == PingFrame.FIXED_FRAME_LENGTH, + message: 'Ping frames must have a length of 8.'); + var opaqueData = readInt64(bytes, offset); + return PingFrame(header, opaqueData); + + case FrameType.GOAWAY: + _checkFrameLengthCondition((frameEnd - offset) >= 8); + var lastStreamId = readInt32(bytes, offset); + var errorCode = readInt32(bytes, offset + 4); + var debugData = viewOrSublist(bytes, offset + 8, header.length - 8); + return GoawayFrame(header, lastStreamId, errorCode, debugData); + + case FrameType.WINDOW_UPDATE: + _checkFrameLengthCondition( + (frameEnd - offset) == WindowUpdateFrame.FIXED_FRAME_LENGTH, + message: 'Window update frames must have a length of 4.'); + var windowSizeIncrement = readInt32(bytes, offset) & 0x7fffffff; + return WindowUpdateFrame(header, windowSizeIncrement); + + case FrameType.CONTINUATION: + var headerBlockFragment = + viewOrSublist(bytes, offset, frameEnd - offset); + return ContinuationFrame(header, headerBlockFragment); + + default: + // Unknown frames should be ignored according to spec. + return UnknownFrame( + header, viewOrSublist(bytes, offset, frameEnd - offset)); + } + } + + /// Checks that [condition] is `true` and raises an [FrameSizeException] + /// otherwise. + void _checkFrameLengthCondition(bool condition, + {String message = 'Frame not long enough.'}) { + if (!condition) { + throw FrameSizeException(message); + } + } +} diff --git a/pkgs/http2/lib/src/frames/frame_types.dart b/pkgs/http2/lib/src/frames/frame_types.dart new file mode 100644 index 0000000000..33c9e4aff1 --- /dev/null +++ b/pkgs/http2/lib/src/frames/frame_types.dart @@ -0,0 +1,348 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of 'frames.dart'; + +const int FRAME_HEADER_SIZE = 9; + +class FrameType { + static const int DATA = 0; + static const int HEADERS = 1; + static const int PRIORITY = 2; + static const int RST_STREAM = 3; + static const int SETTINGS = 4; + static const int PUSH_PROMISE = 5; + static const int PING = 6; + static const int GOAWAY = 7; + static const int WINDOW_UPDATE = 8; + static const int CONTINUATION = 9; +} + +class ErrorCode { + static const int NO_ERROR = 0; + static const int PROTOCOL_ERROR = 1; + static const int INTERNAL_ERROR = 2; + static const int FLOW_CONTROL_ERROR = 3; + static const int SETTINGS_TIMEOUT = 4; + static const int STREAM_CLOSED = 5; + static const int FRAME_SIZE_ERROR = 6; + static const int REFUSED_STREAM = 7; + static const int CANCEL = 8; + static const int COMPRESSION_ERROR = 9; + static const int CONNECT_ERROR = 10; + static const int ENHANCE_YOUR_CALM = 11; + static const int INADEQUATE_SECURITY = 12; + static const int HTTP_1_1_REQUIRED = 13; +} + +class FrameHeader { + final int length; + final int type; + final int flags; + final int streamId; + + FrameHeader(this.length, this.type, this.flags, this.streamId); + + Map toJson() => + {'length': length, 'type': type, 'flags': flags, 'streamId': streamId}; +} + +class Frame { + static const int MAX_LEN = (1 << 24) - 1; + + final FrameHeader header; + + Frame(this.header); + + Map toJson() => {'header': header.toJson()}; +} + +class DataFrame extends Frame { + static const int FLAG_END_STREAM = 0x1; + static const int FLAG_PADDED = 0x8; + + /// The number of padding bytes. + final int padLength; + + final List bytes; + + DataFrame(super.header, this.padLength, this.bytes); + + bool get hasEndStreamFlag => _isFlagSet(header.flags, FLAG_END_STREAM); + bool get hasPaddedFlag => _isFlagSet(header.flags, FLAG_PADDED); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'padLength': padLength, + 'bytes (length)': bytes.length, + 'bytes (up to 4 bytes)': bytes.length > 4 ? bytes.sublist(0, 4) : bytes, + }); +} + +class HeadersFrame extends Frame { + static const int FLAG_END_STREAM = 0x1; + static const int FLAG_END_HEADERS = 0x4; + static const int FLAG_PADDED = 0x8; + static const int FLAG_PRIORITY = 0x20; + + // NOTE: This is the size a [HeadersFrame] can have in addition to padding + // and header block fragment data. + static const int MAX_CONSTANT_PAYLOAD = 6; + + /// The number of padding bytes (might be null). + final int padLength; + + final bool exclusiveDependency; + final int? streamDependency; + final int? weight; + final List headerBlockFragment; + + HeadersFrame( + super.header, + this.padLength, + this.exclusiveDependency, + this.streamDependency, + this.weight, + this.headerBlockFragment, + ); + + /// This will be set from the outside after decoding. + late List
decodedHeaders; + + bool get hasEndStreamFlag => _isFlagSet(header.flags, FLAG_END_STREAM); + bool get hasEndHeadersFlag => _isFlagSet(header.flags, FLAG_END_HEADERS); + bool get hasPaddedFlag => _isFlagSet(header.flags, FLAG_PADDED); + bool get hasPriorityFlag => _isFlagSet(header.flags, FLAG_PRIORITY); + + HeadersFrame addBlockContinuation(ContinuationFrame frame) { + var fragment = frame.headerBlockFragment; + var flags = header.flags | frame.header.flags; + var fh = FrameHeader( + header.length + fragment.length, header.type, flags, header.streamId); + + var mergedHeaderBlockFragment = + Uint8List(headerBlockFragment.length + fragment.length); + + mergedHeaderBlockFragment.setRange( + 0, headerBlockFragment.length, headerBlockFragment); + + mergedHeaderBlockFragment.setRange( + headerBlockFragment.length, mergedHeaderBlockFragment.length, fragment); + + return HeadersFrame(fh, padLength, exclusiveDependency, streamDependency, + weight, mergedHeaderBlockFragment); + } + + @override + Map toJson() => super.toJson() + ..addAll({ + 'padLength': padLength, + 'exclusiveDependency': exclusiveDependency, + 'streamDependency': streamDependency, + 'weight': weight, + 'headerBlockFragment (length)': headerBlockFragment.length + }); +} + +class PriorityFrame extends Frame { + static const int FIXED_FRAME_LENGTH = 5; + + final bool exclusiveDependency; + final int streamDependency; + final int weight; + + PriorityFrame( + super.header, + this.exclusiveDependency, + this.streamDependency, + this.weight, + ); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'exclusiveDependency': exclusiveDependency, + 'streamDependency': streamDependency, + 'weight': weight, + }); +} + +class RstStreamFrame extends Frame { + static const int FIXED_FRAME_LENGTH = 4; + + final int errorCode; + + RstStreamFrame(super.header, this.errorCode); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'errorCode': errorCode, + }); +} + +class Setting { + static const int SETTINGS_HEADER_TABLE_SIZE = 1; + static const int SETTINGS_ENABLE_PUSH = 2; + static const int SETTINGS_MAX_CONCURRENT_STREAMS = 3; + static const int SETTINGS_INITIAL_WINDOW_SIZE = 4; + static const int SETTINGS_MAX_FRAME_SIZE = 5; + static const int SETTINGS_MAX_HEADER_LIST_SIZE = 6; + + final int identifier; + final int value; + + Setting(this.identifier, this.value); + + Map toJson() => {'identifier': identifier, 'value': value}; +} + +class SettingsFrame extends Frame { + static const int FLAG_ACK = 0x1; + + // A setting consist of a 2 byte identifier and a 4 byte value. + static const int SETTING_SIZE = 6; + + final List settings; + + SettingsFrame(super.header, this.settings); + + bool get hasAckFlag => _isFlagSet(header.flags, FLAG_ACK); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'settings': settings.map((s) => s.toJson()).toList(), + }); +} + +class PushPromiseFrame extends Frame { + static const int FLAG_END_HEADERS = 0x4; + static const int FLAG_PADDED = 0x8; + + // NOTE: This is the size a [PushPromiseFrame] can have in addition to padding + // and header block fragment data. + static const int MAX_CONSTANT_PAYLOAD = 5; + + final int padLength; + final int promisedStreamId; + final List headerBlockFragment; + + /// This will be set from the outside after decoding. + late List
decodedHeaders; + + PushPromiseFrame( + super.header, + this.padLength, + this.promisedStreamId, + this.headerBlockFragment, + ); + + bool get hasEndHeadersFlag => _isFlagSet(header.flags, FLAG_END_HEADERS); + bool get hasPaddedFlag => _isFlagSet(header.flags, FLAG_PADDED); + + PushPromiseFrame addBlockContinuation(ContinuationFrame frame) { + var fragment = frame.headerBlockFragment; + var flags = header.flags | frame.header.flags; + var fh = FrameHeader( + header.length + fragment.length, header.type, flags, header.streamId); + + var mergedHeaderBlockFragment = + Uint8List(headerBlockFragment.length + fragment.length); + + mergedHeaderBlockFragment.setRange( + 0, headerBlockFragment.length, headerBlockFragment); + + mergedHeaderBlockFragment.setRange( + headerBlockFragment.length, mergedHeaderBlockFragment.length, fragment); + + return PushPromiseFrame( + fh, padLength, promisedStreamId, mergedHeaderBlockFragment); + } + + @override + Map toJson() => super.toJson() + ..addAll({ + 'padLength': padLength, + 'promisedStreamId': promisedStreamId, + 'headerBlockFragment (len)': headerBlockFragment.length, + }); +} + +class PingFrame extends Frame { + static const int FIXED_FRAME_LENGTH = 8; + + static const int FLAG_ACK = 0x1; + + final int opaqueData; + + PingFrame(super.header, this.opaqueData); + + bool get hasAckFlag => _isFlagSet(header.flags, FLAG_ACK); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'opaqueData': opaqueData, + }); +} + +class GoawayFrame extends Frame { + final int lastStreamId; + final int errorCode; + final List debugData; + + GoawayFrame(super.header, this.lastStreamId, this.errorCode, this.debugData); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'lastStreamId': lastStreamId, + 'errorCode': errorCode, + 'debugData (length)': debugData.length, + }); +} + +class WindowUpdateFrame extends Frame { + static const int FIXED_FRAME_LENGTH = 4; + + final int windowSizeIncrement; + + WindowUpdateFrame(super.header, this.windowSizeIncrement); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'windowSizeIncrement': windowSizeIncrement, + }); +} + +class ContinuationFrame extends Frame { + static const int FLAG_END_HEADERS = 0x4; + + final List headerBlockFragment; + + ContinuationFrame(super.header, this.headerBlockFragment); + + bool get hasEndHeadersFlag => _isFlagSet(header.flags, FLAG_END_HEADERS); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'headerBlockFragment (length)': headerBlockFragment.length, + }); +} + +class UnknownFrame extends Frame { + final List data; + + UnknownFrame(super.header, this.data); + + @override + Map toJson() => super.toJson() + ..addAll({ + 'data (length)': data.length, + }); +} diff --git a/pkgs/http2/lib/src/frames/frame_utils.dart b/pkgs/http2/lib/src/frames/frame_utils.dart new file mode 100644 index 0000000000..11c30a5f96 --- /dev/null +++ b/pkgs/http2/lib/src/frames/frame_utils.dart @@ -0,0 +1,7 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of 'frames.dart'; + +bool _isFlagSet(int value, int flag) => value & flag == flag; diff --git a/pkgs/http2/lib/src/frames/frame_writer.dart b/pkgs/http2/lib/src/frames/frame_writer.dart new file mode 100644 index 0000000000..50caad75c9 --- /dev/null +++ b/pkgs/http2/lib/src/frames/frame_writer.dart @@ -0,0 +1,292 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of 'frames.dart'; + +// TODO: No support for writing padded information. +// TODO: No support for stream priorities. +class FrameWriter { + /// The HPack compression context. + final HPackEncoder _hpackEncoder; + + /// A buffered writer for outgoing bytes. + final BufferedBytesWriter _outWriter; + + /// Connection settings which this writer needs to respect. + final ActiveSettings _peerSettings; + + /// This is the maximum over all stream id's we've written to the underlying + /// sink. + int _highestWrittenStreamId = 0; + + FrameWriter( + this._hpackEncoder, StreamSink> outgoing, this._peerSettings) + : _outWriter = BufferedBytesWriter(outgoing); + + /// A indicator whether writes would be buffered. + BufferIndicator get bufferIndicator => _outWriter.bufferIndicator; + + /// This is the maximum over all stream id's we've written to the underlying + /// sink. + int get highestWrittenStreamId => _highestWrittenStreamId; + + void writeDataFrame(int streamId, List data, {bool endStream = false}) { + while (data.length > _peerSettings.maxFrameSize) { + var chunk = viewOrSublist(data, 0, _peerSettings.maxFrameSize); + data = viewOrSublist(data, _peerSettings.maxFrameSize, + data.length - _peerSettings.maxFrameSize); + _writeDataFrameNoFragment(streamId, chunk, false); + } + _writeDataFrameNoFragment(streamId, data, endStream); + } + + void _writeDataFrameNoFragment(int streamId, List data, bool endStream) { + var type = FrameType.DATA; + var flags = endStream ? DataFrame.FLAG_END_STREAM : 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + data.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, data.length); + offset += FRAME_HEADER_SIZE; + + buffer.setRange(offset, offset + data.length, data); + + _writeData(buffer); + } + + void writeHeadersFrame(int streamId, List
headers, + {bool endStream = true}) { + var fragment = _hpackEncoder.encode(headers); + var maxSize = + _peerSettings.maxFrameSize - HeadersFrame.MAX_CONSTANT_PAYLOAD; + + if (fragment.length < maxSize) { + _writeHeadersFrameNoFragment(streamId, fragment, true, endStream); + } else { + var chunk = fragment.sublist(0, maxSize); + fragment = fragment.sublist(maxSize); + _writeHeadersFrameNoFragment(streamId, chunk, false, endStream); + while (fragment.length > _peerSettings.maxFrameSize) { + var chunk = fragment.sublist(0, _peerSettings.maxFrameSize); + fragment = fragment.sublist(_peerSettings.maxFrameSize); + _writeContinuationFrame(streamId, chunk, false); + } + _writeContinuationFrame(streamId, fragment, true); + } + } + + void _writeHeadersFrameNoFragment( + int streamId, List fragment, bool endHeaders, bool endStream) { + var type = FrameType.HEADERS; + var flags = 0; + if (endHeaders) flags |= HeadersFrame.FLAG_END_HEADERS; + if (endStream) flags |= HeadersFrame.FLAG_END_STREAM; + + var buffer = Uint8List(FRAME_HEADER_SIZE + fragment.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, fragment.length); + offset += FRAME_HEADER_SIZE; + + buffer.setRange(offset, buffer.length, fragment); + + _writeData(buffer); + } + + void _writeContinuationFrame( + int streamId, List fragment, bool endHeaders) { + var type = FrameType.CONTINUATION; + var flags = endHeaders ? ContinuationFrame.FLAG_END_HEADERS : 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + fragment.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, fragment.length); + offset += FRAME_HEADER_SIZE; + + buffer.setRange(offset, buffer.length, fragment); + + _writeData(buffer); + } + + void writePriorityFrame(int streamId, int streamDependency, int weight, + {bool exclusive = false}) { + var type = FrameType.PRIORITY; + var flags = 0; + + var buffer = + Uint8List(FRAME_HEADER_SIZE + PriorityFrame.FIXED_FRAME_LENGTH); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, 5); + offset += FRAME_HEADER_SIZE; + + if (exclusive) { + setInt32(buffer, offset, (1 << 31) | streamDependency); + } else { + setInt32(buffer, offset, streamDependency); + } + buffer[offset + 4] = weight; + + _writeData(buffer); + } + + void writeRstStreamFrame(int streamId, int errorCode) { + var type = FrameType.RST_STREAM; + var flags = 0; + + var buffer = + Uint8List(FRAME_HEADER_SIZE + RstStreamFrame.FIXED_FRAME_LENGTH); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, 4); + offset += FRAME_HEADER_SIZE; + + setInt32(buffer, offset, errorCode); + + _writeData(buffer); + } + + void writeSettingsFrame(List settings) { + var type = FrameType.SETTINGS; + var flags = 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + 6 * settings.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, 0, 6 * settings.length); + offset += FRAME_HEADER_SIZE; + + for (var i = 0; i < settings.length; i++) { + var setting = settings[i]; + setInt16(buffer, offset + 6 * i, setting.identifier); + setInt32(buffer, offset + 6 * i + 2, setting.value); + } + + _writeData(buffer); + } + + void writeSettingsAckFrame() { + var type = FrameType.SETTINGS; + var flags = SettingsFrame.FLAG_ACK; + + var buffer = Uint8List(FRAME_HEADER_SIZE); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, 0, 0); + offset += FRAME_HEADER_SIZE; + + _writeData(buffer); + } + + void writePushPromiseFrame( + int streamId, int promisedStreamId, List
headers) { + var fragment = _hpackEncoder.encode(headers); + var maxSize = + _peerSettings.maxFrameSize - PushPromiseFrame.MAX_CONSTANT_PAYLOAD; + + if (fragment.length < maxSize) { + _writePushPromiseFrameNoFragmentation( + streamId, promisedStreamId, fragment, true); + } else { + var chunk = fragment.sublist(0, maxSize); + fragment = fragment.sublist(maxSize); + _writePushPromiseFrameNoFragmentation( + streamId, promisedStreamId, chunk, false); + while (fragment.length > _peerSettings.maxFrameSize) { + var chunk = fragment.sublist(0, _peerSettings.maxFrameSize); + fragment = fragment.sublist(_peerSettings.maxFrameSize); + _writeContinuationFrame(streamId, chunk, false); + } + _writeContinuationFrame(streamId, chunk, true); + } + } + + void _writePushPromiseFrameNoFragmentation( + int streamId, int promisedStreamId, List fragment, bool endHeaders) { + var type = FrameType.PUSH_PROMISE; + var flags = endHeaders ? HeadersFrame.FLAG_END_HEADERS : 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + 4 + fragment.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, 4 + fragment.length); + offset += FRAME_HEADER_SIZE; + + setInt32(buffer, offset, promisedStreamId); + buffer.setRange(offset + 4, offset + 4 + fragment.length, fragment); + + _writeData(buffer); + } + + void writePingFrame(int opaqueData, {bool ack = false}) { + var type = FrameType.PING; + var flags = ack ? PingFrame.FLAG_ACK : 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + PingFrame.FIXED_FRAME_LENGTH); + var offset = 0; + + _setFrameHeader(buffer, 0, type, flags, 0, 8); + offset += FRAME_HEADER_SIZE; + + setInt64(buffer, offset, opaqueData); + _writeData(buffer); + } + + void writeGoawayFrame(int lastStreamId, int errorCode, List debugData) { + var type = FrameType.GOAWAY; + var flags = 0; + + var buffer = Uint8List(FRAME_HEADER_SIZE + 8 + debugData.length); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, 0, 8 + debugData.length); + offset += FRAME_HEADER_SIZE; + + setInt32(buffer, offset, lastStreamId); + setInt32(buffer, offset + 4, errorCode); + buffer.setRange(offset + 8, buffer.length, debugData); + + _writeData(buffer); + } + + void writeWindowUpdate(int sizeIncrement, {int streamId = 0}) { + var type = FrameType.WINDOW_UPDATE; + var flags = 0; + + var buffer = + Uint8List(FRAME_HEADER_SIZE + WindowUpdateFrame.FIXED_FRAME_LENGTH); + var offset = 0; + + _setFrameHeader(buffer, offset, type, flags, streamId, 4); + offset += FRAME_HEADER_SIZE; + + setInt32(buffer, offset, sizeIncrement); + + _writeData(buffer); + } + + void _writeData(List bytes) { + _outWriter.add(bytes); + } + + /// Closes the underlying sink and returns [doneFuture]. + Future close() { + return _outWriter.close().whenComplete(() => doneFuture); + } + + /// The future which will complete once this writer is done. + Future get doneFuture => _outWriter.doneFuture; + + void _setFrameHeader(List bytes, int offset, int type, int flags, + int streamId, int length) { + setInt24(bytes, offset, length); + bytes[3] = type; + bytes[4] = flags; + setInt32(bytes, 5, streamId); + + _highestWrittenStreamId = max(_highestWrittenStreamId, streamId); + } +} diff --git a/pkgs/http2/lib/src/frames/frames.dart b/pkgs/http2/lib/src/frames/frames.dart new file mode 100644 index 0000000000..f6e74d250f --- /dev/null +++ b/pkgs/http2/lib/src/frames/frames.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library http2.src.frames; + +import 'dart:async'; +import 'dart:math' show max; +import 'dart:typed_data'; + +import '../async_utils/async_utils.dart'; +import '../byte_utils.dart'; +import '../hpack/hpack.dart'; +import '../settings/settings.dart'; +import '../sync_errors.dart'; + +part 'frame_types.dart'; +part 'frame_utils.dart'; +part 'frame_reader.dart'; +part 'frame_writer.dart'; diff --git a/pkgs/http2/lib/src/hpack/hpack.dart b/pkgs/http2/lib/src/hpack/hpack.dart new file mode 100644 index 0000000000..ab5d9c8fa8 --- /dev/null +++ b/pkgs/http2/lib/src/hpack/hpack.dart @@ -0,0 +1,347 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Implements a [HPackContext] for encoding/decoding headers according to the +/// HPACK specificaiton. See here for more information: +/// https://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10 +library http2.hpack; + +import 'dart:convert' show ascii; +import 'dart:typed_data'; + +import '../byte_utils.dart'; +import 'huffman.dart'; +import 'huffman_table.dart'; + +/// Exception raised due to encoding/decoding errors. +class HPackDecodingException implements Exception { + final String _message; + + HPackDecodingException(this._message); + + @override + String toString() => 'HPackDecodingException: $_message'; +} + +/// A HPACK encoding/decoding context. +/// +/// This is a statefull class, so encoding/decoding changes internal state. +class HPackContext { + final HPackEncoder encoder = HPackEncoder(); + final HPackDecoder decoder = HPackDecoder(); + + HPackContext( + {int maxSendingHeaderTableSize = 4096, + int maxReceivingHeaderTableSize = 4096}) { + encoder.updateMaxSendingHeaderTableSize(maxSendingHeaderTableSize); + decoder.updateMaxReceivingHeaderTableSize(maxReceivingHeaderTableSize); + } +} + +/// A HTTP/2 header. +class Header { + final List name; + final List value; + final bool neverIndexed; + + Header(this.name, this.value, {this.neverIndexed = false}); + + factory Header.ascii(String name, String value) { + // Specs: `However, header field names MUST be converted to lowercase prior + // to their encoding in HTTP/2. A request or response containing uppercase + // header field names MUST be treated as malformed (Section 8.1.2.6).` + return Header(ascii.encode(name.toLowerCase()), ascii.encode(value)); + } +} + +/// A stateful HPACK decoder. +class HPackDecoder { + late int _maxHeaderTableSize; + + final IndexTable _table = IndexTable(); + + void updateMaxReceivingHeaderTableSize(int newMaximumSize) { + _maxHeaderTableSize = newMaximumSize; + } + + List
decode(List data) { + var offset = 0; + + int readInteger(int prefixBits) { + assert(prefixBits <= 8 && prefixBits > 0); + + var byte = data[offset++] & ((1 << prefixBits) - 1); + + int integer; + if (byte == ((1 << prefixBits) - 1)) { + // Length encodeded. + integer = 0; + var shift = 0; + while (true) { + var done = (data[offset] & 0x80) != 0x80; + integer += (data[offset++] & 0x7f) << shift; + shift += 7; + if (done) break; + } + integer += (1 << prefixBits) - 1; + } else { + // In place length. + integer = byte; + } + + return integer; + } + + List readStringLiteral() { + var isHuffmanEncoding = (data[offset] & 0x80) != 0; + var length = readInteger(7); + + var sublist = viewOrSublist(data, offset, length); + offset += length; + if (isHuffmanEncoding) { + return http2HuffmanCodec.decode(sublist); + } else { + return sublist; + } + } + + Header readHeaderFieldInternal(int index, {bool neverIndexed = false}) { + List name, value; + if (index > 0) { + name = _table.lookup(index).name; + value = readStringLiteral(); + } else { + name = readStringLiteral(); + value = readStringLiteral(); + } + return Header(name, value, neverIndexed: neverIndexed); + } + + try { + var headers =
[]; + while (offset < data.length) { + var byte = data[offset]; + var isIndexedField = (byte & 0x80) != 0; + var isIncrementalIndexing = (byte & 0xc0) == 0x40; + + var isWithoutIndexing = (byte & 0xf0) == 0; + var isNeverIndexing = (byte & 0xf0) == 0x10; + var isDynamicTableSizeUpdate = (byte & 0xe0) == 0x20; + + if (isIndexedField) { + var index = readInteger(7); + var field = _table.lookup(index); + headers.add(field); + } else if (isIncrementalIndexing) { + var field = readHeaderFieldInternal(readInteger(6)); + _table.addHeaderField(field); + headers.add(field); + } else if (isWithoutIndexing) { + headers.add(readHeaderFieldInternal(readInteger(4))); + } else if (isNeverIndexing) { + headers + .add(readHeaderFieldInternal(readInteger(4), neverIndexed: true)); + } else if (isDynamicTableSizeUpdate) { + var newMaxSize = readInteger(5); + if (newMaxSize <= _maxHeaderTableSize) { + _table.updateMaxSize(newMaxSize); + } else { + throw HPackDecodingException('Dynamic table size update failed: ' + 'A new value of $newMaxSize exceeds the limit of ' + '$_maxHeaderTableSize'); + } + } else { + throw HPackDecodingException('Invalid encoding of headers.'); + } + } + return headers; + // ignore: avoid_catching_errors + } on RangeError catch (e) { + throw HPackDecodingException('$e'); + } on HuffmanDecodingException catch (e) { + throw HPackDecodingException('$e'); + } + } +} + +/// A stateful HPACK encoder. +// TODO: Currently we encode all headers: +// - without huffman encoding +// - without using the dynamic table +class HPackEncoder { + void updateMaxSendingHeaderTableSize(int newMaximumSize) { + // TODO: Once we start encoding via dynamic table we need to let the other + // side know the maximum table size we're using. + } + + List encode(List
headers) { + var bytesBuilder = BytesBuilder(); + var currentByte = 0; + + void writeInteger(int prefixBits, int value) { + assert(prefixBits <= 8); + + if (value < (1 << prefixBits) - 1) { + currentByte |= value; + bytesBuilder.addByte(currentByte); + } else { + // Length encodeded. + currentByte |= (1 << prefixBits) - 1; + value -= (1 << prefixBits) - 1; + bytesBuilder.addByte(currentByte); + var done = false; + while (!done) { + currentByte = value & 0x7f; + value = value >> 7; + done = value == 0; + if (!done) currentByte |= 0x80; + bytesBuilder.addByte(currentByte); + } + } + currentByte = 0; + } + + void writeStringLiteral(List bytes) { + // TODO: Support huffman encoding. + currentByte = 0; // 1 would be huffman encoding + writeInteger(7, bytes.length); + bytesBuilder.add(bytes); + } + + void writeLiteralHeaderWithoutIndexing(Header header) { + bytesBuilder.addByte(0); + writeStringLiteral(header.name); + writeStringLiteral(header.value); + } + + for (var header in headers) { + writeLiteralHeaderWithoutIndexing(header); + } + + return bytesBuilder.takeBytes(); + } +} + +class IndexTable { + static final List _staticTable = [ + null, + Header(ascii.encode(':authority'), const []), + Header(ascii.encode(':method'), ascii.encode('GET')), + Header(ascii.encode(':method'), ascii.encode('POST')), + Header(ascii.encode(':path'), ascii.encode('/')), + Header(ascii.encode(':path'), ascii.encode('/index.html')), + Header(ascii.encode(':scheme'), ascii.encode('http')), + Header(ascii.encode(':scheme'), ascii.encode('https')), + Header(ascii.encode(':status'), ascii.encode('200')), + Header(ascii.encode(':status'), ascii.encode('204')), + Header(ascii.encode(':status'), ascii.encode('206')), + Header(ascii.encode(':status'), ascii.encode('304')), + Header(ascii.encode(':status'), ascii.encode('400')), + Header(ascii.encode(':status'), ascii.encode('404')), + Header(ascii.encode(':status'), ascii.encode('500')), + Header(ascii.encode('accept-charset'), const []), + Header(ascii.encode('accept-encoding'), ascii.encode('gzip, deflate')), + Header(ascii.encode('accept-language'), const []), + Header(ascii.encode('accept-ranges'), const []), + Header(ascii.encode('accept'), const []), + Header(ascii.encode('access-control-allow-origin'), const []), + Header(ascii.encode('age'), const []), + Header(ascii.encode('allow'), const []), + Header(ascii.encode('authorization'), const []), + Header(ascii.encode('cache-control'), const []), + Header(ascii.encode('content-disposition'), const []), + Header(ascii.encode('content-encoding'), const []), + Header(ascii.encode('content-language'), const []), + Header(ascii.encode('content-length'), const []), + Header(ascii.encode('content-location'), const []), + Header(ascii.encode('content-range'), const []), + Header(ascii.encode('content-type'), const []), + Header(ascii.encode('cookie'), const []), + Header(ascii.encode('date'), const []), + Header(ascii.encode('etag'), const []), + Header(ascii.encode('expect'), const []), + Header(ascii.encode('expires'), const []), + Header(ascii.encode('from'), const []), + Header(ascii.encode('host'), const []), + Header(ascii.encode('if-match'), const []), + Header(ascii.encode('if-modified-since'), const []), + Header(ascii.encode('if-none-match'), const []), + Header(ascii.encode('if-range'), const []), + Header(ascii.encode('if-unmodified-since'), const []), + Header(ascii.encode('last-modified'), const []), + Header(ascii.encode('link'), const []), + Header(ascii.encode('location'), const []), + Header(ascii.encode('max-forwards'), const []), + Header(ascii.encode('proxy-authenticate'), const []), + Header(ascii.encode('proxy-authorization'), const []), + Header(ascii.encode('range'), const []), + Header(ascii.encode('referer'), const []), + Header(ascii.encode('refresh'), const []), + Header(ascii.encode('retry-after'), const []), + Header(ascii.encode('server'), const []), + Header(ascii.encode('set-cookie'), const []), + Header(ascii.encode('strict-transport-security'), const []), + Header(ascii.encode('transfer-encoding'), const []), + Header(ascii.encode('user-agent'), const []), + Header(ascii.encode('vary'), const []), + Header(ascii.encode('via'), const []), + Header(ascii.encode('www-authenticate'), const []), + ]; + + final List
_dynamicTable = []; + + /// The maximum size the dynamic table can grow to before entries need to be + /// evicted. + int _maximumSize = 4096; + + /// The current size of the dynamic table. + int _currentSize = 0; + + IndexTable(); + + /// Updates the maximum size which the dynamic table can grow to. + void updateMaxSize(int newMaxDynTableSize) { + _maximumSize = newMaxDynTableSize; + _reduce(); + } + + /// Lookup an item by index. + Header lookup(int index) { + if (index <= 0) { + throw HPackDecodingException( + 'Invalid index (was: $index) for table lookup.'); + } + if (index < _staticTable.length) { + return _staticTable[index]!; + } + index -= _staticTable.length; + if (index < _dynamicTable.length) { + return _dynamicTable[index]; + } + throw HPackDecodingException( + 'Invalid index (was: $index) for table lookup.'); + } + + /// Adds a new header field to the dynamic table - and evicts entries as + /// necessary. + void addHeaderField(Header header) { + _dynamicTable.insert(0, header); + _currentSize += _sizeOf(header); + _reduce(); + } + + /// Removes as many entries as required to be within the limit of + /// [_maximumSize]. + void _reduce() { + while (_currentSize > _maximumSize) { + var h = _dynamicTable.removeLast(); + _currentSize -= _sizeOf(h); + } + } + + /// Returns the "size" a [header] has. + /// + /// This is specified to be the number of octets of name/value plus 32. + int _sizeOf(Header header) => header.name.length + header.value.length + 32; +} diff --git a/pkgs/http2/lib/src/hpack/huffman.dart b/pkgs/http2/lib/src/hpack/huffman.dart new file mode 100644 index 0000000000..aca4cde481 --- /dev/null +++ b/pkgs/http2/lib/src/hpack/huffman.dart @@ -0,0 +1,180 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; + +import 'huffman_table.dart'; + +class HuffmanDecodingException implements Exception { + final String _message; + + HuffmanDecodingException(this._message); + + @override + String toString() => 'HuffmanDecodingException: $_message'; +} + +/// A codec used for encoding/decoding using a huffman codec. +class HuffmanCodec { + final HuffmanEncoder _encoder; + final HuffmanDecoder _decoder; + + HuffmanCodec(this._encoder, this._decoder); + + List decode(List bytes) => _decoder.decode(bytes); + + List encode(List bytes) => _encoder.encode(bytes); +} + +/// A huffman decoder based on a [HuffmanTreeNode]. +class HuffmanDecoder { + final HuffmanTreeNode _root; + + HuffmanDecoder(this._root); + + /// Decodes [bytes] using a huffman tree. + List decode(List bytes) { + var buffer = BytesBuilder(); + + var currentByteOffset = 0; + var node = _root; + var currentDepth = 0; + while (currentByteOffset < bytes.length) { + var byte = bytes[currentByteOffset]; + for (var currentBit = 7; currentBit >= 0; currentBit--) { + var right = (byte >> currentBit) & 1 == 1; + if (right) { + node = node.right!; + } else { + node = node.left!; + } + currentDepth++; + if (node.value != null) { + if (node.value == EOS_BYTE) { + throw HuffmanDecodingException( + 'More than 7 bit padding is not allowed. Found entire EOS ' + 'encoding'); + } + buffer.addByte(node.value!); + node = _root; + currentDepth = 0; + } + } + currentByteOffset++; + } + + if (node != _root) { + if (currentDepth > 7) { + throw HuffmanDecodingException( + 'Incomplete encoding of a byte or more than 7 bit padding.'); + } + + while (node.right != null) { + node = node.right!; + } + + if (node.value != 256) { + throw HuffmanDecodingException('Incomplete encoding of a byte.'); + } + } + + return buffer.takeBytes(); + } +} + +/// A huffman encoder based on a list of codewords. +class HuffmanEncoder { + final List _codewords; + + HuffmanEncoder(this._codewords); + + /// Encodes [bytes] using a list of codewords. + List encode(List bytes) { + var buffer = BytesBuilder(); + + var currentByte = 0; + var currentBitOffset = 7; + + void writeValue(int value, int numBits) { + var i = numBits - 1; + while (i >= 0) { + if (currentBitOffset == 7 && i >= 7) { + assert(currentByte == 0); + + buffer.addByte((value >> (i - 7)) & 0xff); + currentBitOffset = 7; + currentByte = 0; + i -= 8; + } else { + currentByte |= ((value >> i) & 1) << currentBitOffset; + + currentBitOffset--; + if (currentBitOffset == -1) { + buffer.addByte(currentByte); + currentBitOffset = 7; + currentByte = 0; + } + i--; + } + } + } + + for (var i = 0; i < bytes.length; i++) { + var byte = bytes[i]; + var value = _codewords[byte]; + writeValue(value.encodedBytes, value.numBits); + } + + if (currentBitOffset < 7) { + writeValue(0xff, 1 + currentBitOffset); + } + + return buffer.takeBytes(); + } +} + +/// Specifies the encoding of a specific value using huffman encoding. +class EncodedHuffmanValue { + /// An integer representation of the encoded bit-string. + final int encodedBytes; + + /// The number of bits in [encodedBytes]. + final int numBits; + + const EncodedHuffmanValue(this.encodedBytes, this.numBits); +} + +/// A node in the huffman tree. +class HuffmanTreeNode { + HuffmanTreeNode? left; + HuffmanTreeNode? right; + int? value; +} + +/// Generates a huffman decoding tree. +HuffmanTreeNode generateHuffmanTree(List valueEncodings) { + var root = HuffmanTreeNode(); + + for (var byteOffset = 0; byteOffset < valueEncodings.length; byteOffset++) { + var entry = valueEncodings[byteOffset]; + + var current = root; + for (var bitNr = 0; bitNr < entry.numBits; bitNr++) { + var right = + ((entry.encodedBytes >> (entry.numBits - bitNr - 1)) & 1) == 1; + + if (right) { + current.right ??= HuffmanTreeNode(); + current = current.right!; + } else { + current.left ??= HuffmanTreeNode(); + current = current.left!; + } + } + + current.value = byteOffset; + } + + return root; +} diff --git a/pkgs/http2/lib/src/hpack/huffman_table.dart b/pkgs/http2/lib/src/hpack/huffman_table.dart new file mode 100644 index 0000000000..488e1246d9 --- /dev/null +++ b/pkgs/http2/lib/src/hpack/huffman_table.dart @@ -0,0 +1,275 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'huffman.dart'; + +/// The huffman codec for encoding/decoding HTTP/2 header blocks. +final HuffmanCodec http2HuffmanCodec = HuffmanCodec(HuffmanEncoder(_codeWords), + HuffmanDecoder(generateHuffmanTree(_codeWords))); + +/// This is the integer representing the End-of-String symbol +/// (it is not representable by a byte). +const int EOS_BYTE = 256; + +/// This list of byte encodings via huffman encoding was generated from the +/// HPACK specification. +const List _codeWords = [ + EncodedHuffmanValue(0x1ff8, 13), + EncodedHuffmanValue(0x7fffd8, 23), + EncodedHuffmanValue(0xfffffe2, 28), + EncodedHuffmanValue(0xfffffe3, 28), + EncodedHuffmanValue(0xfffffe4, 28), + EncodedHuffmanValue(0xfffffe5, 28), + EncodedHuffmanValue(0xfffffe6, 28), + EncodedHuffmanValue(0xfffffe7, 28), + EncodedHuffmanValue(0xfffffe8, 28), + EncodedHuffmanValue(0xffffea, 24), + EncodedHuffmanValue(0x3ffffffc, 30), + EncodedHuffmanValue(0xfffffe9, 28), + EncodedHuffmanValue(0xfffffea, 28), + EncodedHuffmanValue(0x3ffffffd, 30), + EncodedHuffmanValue(0xfffffeb, 28), + EncodedHuffmanValue(0xfffffec, 28), + EncodedHuffmanValue(0xfffffed, 28), + EncodedHuffmanValue(0xfffffee, 28), + EncodedHuffmanValue(0xfffffef, 28), + EncodedHuffmanValue(0xffffff0, 28), + EncodedHuffmanValue(0xffffff1, 28), + EncodedHuffmanValue(0xffffff2, 28), + EncodedHuffmanValue(0x3ffffffe, 30), + EncodedHuffmanValue(0xffffff3, 28), + EncodedHuffmanValue(0xffffff4, 28), + EncodedHuffmanValue(0xffffff5, 28), + EncodedHuffmanValue(0xffffff6, 28), + EncodedHuffmanValue(0xffffff7, 28), + EncodedHuffmanValue(0xffffff8, 28), + EncodedHuffmanValue(0xffffff9, 28), + EncodedHuffmanValue(0xffffffa, 28), + EncodedHuffmanValue(0xffffffb, 28), + EncodedHuffmanValue(0x14, 6), + EncodedHuffmanValue(0x3f8, 10), + EncodedHuffmanValue(0x3f9, 10), + EncodedHuffmanValue(0xffa, 12), + EncodedHuffmanValue(0x1ff9, 13), + EncodedHuffmanValue(0x15, 6), + EncodedHuffmanValue(0xf8, 8), + EncodedHuffmanValue(0x7fa, 11), + EncodedHuffmanValue(0x3fa, 10), + EncodedHuffmanValue(0x3fb, 10), + EncodedHuffmanValue(0xf9, 8), + EncodedHuffmanValue(0x7fb, 11), + EncodedHuffmanValue(0xfa, 8), + EncodedHuffmanValue(0x16, 6), + EncodedHuffmanValue(0x17, 6), + EncodedHuffmanValue(0x18, 6), + EncodedHuffmanValue(0x0, 5), + EncodedHuffmanValue(0x1, 5), + EncodedHuffmanValue(0x2, 5), + EncodedHuffmanValue(0x19, 6), + EncodedHuffmanValue(0x1a, 6), + EncodedHuffmanValue(0x1b, 6), + EncodedHuffmanValue(0x1c, 6), + EncodedHuffmanValue(0x1d, 6), + EncodedHuffmanValue(0x1e, 6), + EncodedHuffmanValue(0x1f, 6), + EncodedHuffmanValue(0x5c, 7), + EncodedHuffmanValue(0xfb, 8), + EncodedHuffmanValue(0x7ffc, 15), + EncodedHuffmanValue(0x20, 6), + EncodedHuffmanValue(0xffb, 12), + EncodedHuffmanValue(0x3fc, 10), + EncodedHuffmanValue(0x1ffa, 13), + EncodedHuffmanValue(0x21, 6), + EncodedHuffmanValue(0x5d, 7), + EncodedHuffmanValue(0x5e, 7), + EncodedHuffmanValue(0x5f, 7), + EncodedHuffmanValue(0x60, 7), + EncodedHuffmanValue(0x61, 7), + EncodedHuffmanValue(0x62, 7), + EncodedHuffmanValue(0x63, 7), + EncodedHuffmanValue(0x64, 7), + EncodedHuffmanValue(0x65, 7), + EncodedHuffmanValue(0x66, 7), + EncodedHuffmanValue(0x67, 7), + EncodedHuffmanValue(0x68, 7), + EncodedHuffmanValue(0x69, 7), + EncodedHuffmanValue(0x6a, 7), + EncodedHuffmanValue(0x6b, 7), + EncodedHuffmanValue(0x6c, 7), + EncodedHuffmanValue(0x6d, 7), + EncodedHuffmanValue(0x6e, 7), + EncodedHuffmanValue(0x6f, 7), + EncodedHuffmanValue(0x70, 7), + EncodedHuffmanValue(0x71, 7), + EncodedHuffmanValue(0x72, 7), + EncodedHuffmanValue(0xfc, 8), + EncodedHuffmanValue(0x73, 7), + EncodedHuffmanValue(0xfd, 8), + EncodedHuffmanValue(0x1ffb, 13), + EncodedHuffmanValue(0x7fff0, 19), + EncodedHuffmanValue(0x1ffc, 13), + EncodedHuffmanValue(0x3ffc, 14), + EncodedHuffmanValue(0x22, 6), + EncodedHuffmanValue(0x7ffd, 15), + EncodedHuffmanValue(0x3, 5), + EncodedHuffmanValue(0x23, 6), + EncodedHuffmanValue(0x4, 5), + EncodedHuffmanValue(0x24, 6), + EncodedHuffmanValue(0x5, 5), + EncodedHuffmanValue(0x25, 6), + EncodedHuffmanValue(0x26, 6), + EncodedHuffmanValue(0x27, 6), + EncodedHuffmanValue(0x6, 5), + EncodedHuffmanValue(0x74, 7), + EncodedHuffmanValue(0x75, 7), + EncodedHuffmanValue(0x28, 6), + EncodedHuffmanValue(0x29, 6), + EncodedHuffmanValue(0x2a, 6), + EncodedHuffmanValue(0x7, 5), + EncodedHuffmanValue(0x2b, 6), + EncodedHuffmanValue(0x76, 7), + EncodedHuffmanValue(0x2c, 6), + EncodedHuffmanValue(0x8, 5), + EncodedHuffmanValue(0x9, 5), + EncodedHuffmanValue(0x2d, 6), + EncodedHuffmanValue(0x77, 7), + EncodedHuffmanValue(0x78, 7), + EncodedHuffmanValue(0x79, 7), + EncodedHuffmanValue(0x7a, 7), + EncodedHuffmanValue(0x7b, 7), + EncodedHuffmanValue(0x7ffe, 15), + EncodedHuffmanValue(0x7fc, 11), + EncodedHuffmanValue(0x3ffd, 14), + EncodedHuffmanValue(0x1ffd, 13), + EncodedHuffmanValue(0xffffffc, 28), + EncodedHuffmanValue(0xfffe6, 20), + EncodedHuffmanValue(0x3fffd2, 22), + EncodedHuffmanValue(0xfffe7, 20), + EncodedHuffmanValue(0xfffe8, 20), + EncodedHuffmanValue(0x3fffd3, 22), + EncodedHuffmanValue(0x3fffd4, 22), + EncodedHuffmanValue(0x3fffd5, 22), + EncodedHuffmanValue(0x7fffd9, 23), + EncodedHuffmanValue(0x3fffd6, 22), + EncodedHuffmanValue(0x7fffda, 23), + EncodedHuffmanValue(0x7fffdb, 23), + EncodedHuffmanValue(0x7fffdc, 23), + EncodedHuffmanValue(0x7fffdd, 23), + EncodedHuffmanValue(0x7fffde, 23), + EncodedHuffmanValue(0xffffeb, 24), + EncodedHuffmanValue(0x7fffdf, 23), + EncodedHuffmanValue(0xffffec, 24), + EncodedHuffmanValue(0xffffed, 24), + EncodedHuffmanValue(0x3fffd7, 22), + EncodedHuffmanValue(0x7fffe0, 23), + EncodedHuffmanValue(0xffffee, 24), + EncodedHuffmanValue(0x7fffe1, 23), + EncodedHuffmanValue(0x7fffe2, 23), + EncodedHuffmanValue(0x7fffe3, 23), + EncodedHuffmanValue(0x7fffe4, 23), + EncodedHuffmanValue(0x1fffdc, 21), + EncodedHuffmanValue(0x3fffd8, 22), + EncodedHuffmanValue(0x7fffe5, 23), + EncodedHuffmanValue(0x3fffd9, 22), + EncodedHuffmanValue(0x7fffe6, 23), + EncodedHuffmanValue(0x7fffe7, 23), + EncodedHuffmanValue(0xffffef, 24), + EncodedHuffmanValue(0x3fffda, 22), + EncodedHuffmanValue(0x1fffdd, 21), + EncodedHuffmanValue(0xfffe9, 20), + EncodedHuffmanValue(0x3fffdb, 22), + EncodedHuffmanValue(0x3fffdc, 22), + EncodedHuffmanValue(0x7fffe8, 23), + EncodedHuffmanValue(0x7fffe9, 23), + EncodedHuffmanValue(0x1fffde, 21), + EncodedHuffmanValue(0x7fffea, 23), + EncodedHuffmanValue(0x3fffdd, 22), + EncodedHuffmanValue(0x3fffde, 22), + EncodedHuffmanValue(0xfffff0, 24), + EncodedHuffmanValue(0x1fffdf, 21), + EncodedHuffmanValue(0x3fffdf, 22), + EncodedHuffmanValue(0x7fffeb, 23), + EncodedHuffmanValue(0x7fffec, 23), + EncodedHuffmanValue(0x1fffe0, 21), + EncodedHuffmanValue(0x1fffe1, 21), + EncodedHuffmanValue(0x3fffe0, 22), + EncodedHuffmanValue(0x1fffe2, 21), + EncodedHuffmanValue(0x7fffed, 23), + EncodedHuffmanValue(0x3fffe1, 22), + EncodedHuffmanValue(0x7fffee, 23), + EncodedHuffmanValue(0x7fffef, 23), + EncodedHuffmanValue(0xfffea, 20), + EncodedHuffmanValue(0x3fffe2, 22), + EncodedHuffmanValue(0x3fffe3, 22), + EncodedHuffmanValue(0x3fffe4, 22), + EncodedHuffmanValue(0x7ffff0, 23), + EncodedHuffmanValue(0x3fffe5, 22), + EncodedHuffmanValue(0x3fffe6, 22), + EncodedHuffmanValue(0x7ffff1, 23), + EncodedHuffmanValue(0x3ffffe0, 26), + EncodedHuffmanValue(0x3ffffe1, 26), + EncodedHuffmanValue(0xfffeb, 20), + EncodedHuffmanValue(0x7fff1, 19), + EncodedHuffmanValue(0x3fffe7, 22), + EncodedHuffmanValue(0x7ffff2, 23), + EncodedHuffmanValue(0x3fffe8, 22), + EncodedHuffmanValue(0x1ffffec, 25), + EncodedHuffmanValue(0x3ffffe2, 26), + EncodedHuffmanValue(0x3ffffe3, 26), + EncodedHuffmanValue(0x3ffffe4, 26), + EncodedHuffmanValue(0x7ffffde, 27), + EncodedHuffmanValue(0x7ffffdf, 27), + EncodedHuffmanValue(0x3ffffe5, 26), + EncodedHuffmanValue(0xfffff1, 24), + EncodedHuffmanValue(0x1ffffed, 25), + EncodedHuffmanValue(0x7fff2, 19), + EncodedHuffmanValue(0x1fffe3, 21), + EncodedHuffmanValue(0x3ffffe6, 26), + EncodedHuffmanValue(0x7ffffe0, 27), + EncodedHuffmanValue(0x7ffffe1, 27), + EncodedHuffmanValue(0x3ffffe7, 26), + EncodedHuffmanValue(0x7ffffe2, 27), + EncodedHuffmanValue(0xfffff2, 24), + EncodedHuffmanValue(0x1fffe4, 21), + EncodedHuffmanValue(0x1fffe5, 21), + EncodedHuffmanValue(0x3ffffe8, 26), + EncodedHuffmanValue(0x3ffffe9, 26), + EncodedHuffmanValue(0xffffffd, 28), + EncodedHuffmanValue(0x7ffffe3, 27), + EncodedHuffmanValue(0x7ffffe4, 27), + EncodedHuffmanValue(0x7ffffe5, 27), + EncodedHuffmanValue(0xfffec, 20), + EncodedHuffmanValue(0xfffff3, 24), + EncodedHuffmanValue(0xfffed, 20), + EncodedHuffmanValue(0x1fffe6, 21), + EncodedHuffmanValue(0x3fffe9, 22), + EncodedHuffmanValue(0x1fffe7, 21), + EncodedHuffmanValue(0x1fffe8, 21), + EncodedHuffmanValue(0x7ffff3, 23), + EncodedHuffmanValue(0x3fffea, 22), + EncodedHuffmanValue(0x3fffeb, 22), + EncodedHuffmanValue(0x1ffffee, 25), + EncodedHuffmanValue(0x1ffffef, 25), + EncodedHuffmanValue(0xfffff4, 24), + EncodedHuffmanValue(0xfffff5, 24), + EncodedHuffmanValue(0x3ffffea, 26), + EncodedHuffmanValue(0x7ffff4, 23), + EncodedHuffmanValue(0x3ffffeb, 26), + EncodedHuffmanValue(0x7ffffe6, 27), + EncodedHuffmanValue(0x3ffffec, 26), + EncodedHuffmanValue(0x3ffffed, 26), + EncodedHuffmanValue(0x7ffffe7, 27), + EncodedHuffmanValue(0x7ffffe8, 27), + EncodedHuffmanValue(0x7ffffe9, 27), + EncodedHuffmanValue(0x7ffffea, 27), + EncodedHuffmanValue(0x7ffffeb, 27), + EncodedHuffmanValue(0xffffffe, 28), + EncodedHuffmanValue(0x7ffffec, 27), + EncodedHuffmanValue(0x7ffffed, 27), + EncodedHuffmanValue(0x7ffffee, 27), + EncodedHuffmanValue(0x7ffffef, 27), + EncodedHuffmanValue(0x7fffff0, 27), + EncodedHuffmanValue(0x3ffffee, 26), + EncodedHuffmanValue(0x3fffffff, 30), +]; diff --git a/pkgs/http2/lib/src/ping/ping_handler.dart b/pkgs/http2/lib/src/ping/ping_handler.dart new file mode 100644 index 0000000000..f9be1f9b44 --- /dev/null +++ b/pkgs/http2/lib/src/ping/ping_handler.dart @@ -0,0 +1,71 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import '../error_handler.dart'; +import '../frames/frames.dart'; +import '../sync_errors.dart'; + +/// Responsible for pinging the other end and for handling pings from the +/// other end. +// TODO: We currently write unconditionally to the [FrameWriter]: we might want +// to consider be more aware what [Framewriter.bufferIndicator.wouldBuffer] +// says. +class PingHandler extends Object with TerminatableMixin { + final FrameWriter _frameWriter; + final Map _remainingPings = {}; + final Sink? pingReceived; + final bool Function() isListeningToPings; + int _nextId = 1; + + PingHandler(this._frameWriter, StreamController pingStream) + : pingReceived = pingStream.sink, + isListeningToPings = (() => pingStream.hasListener); + + @override + void onTerminated(Object? error) { + final remainingPings = _remainingPings.values.toList(); + _remainingPings.clear(); + for (final ping in remainingPings) { + ping.completeError( + error ?? 'Remaining ping completed with unspecified error'); + } + } + + void processPingFrame(PingFrame frame) { + ensureNotTerminatedSync(() { + if (frame.header.streamId != 0) { + throw ProtocolException('Ping frames must have a stream id of 0.'); + } + + if (!frame.hasAckFlag) { + if (isListeningToPings()) { + pingReceived?.add(frame.opaqueData); + } + _frameWriter.writePingFrame(frame.opaqueData, ack: true); + } else { + var c = _remainingPings.remove(frame.opaqueData); + if (c != null) { + c.complete(); + } else { + // NOTE: It is not specified what happens when one gets an ACK for a + // ping we never sent. We be very strict and fail in this case. + throw ProtocolException( + 'Received ping ack with unknown opaque data.'); + } + } + }); + } + + Future ping() { + return ensureNotTerminatedAsync(() { + var c = Completer(); + var id = _nextId++; + _remainingPings[id] = c; + _frameWriter.writePingFrame(id); + return c.future; + }); + } +} diff --git a/pkgs/http2/lib/src/settings/settings.dart b/pkgs/http2/lib/src/settings/settings.dart new file mode 100644 index 0000000000..291c66856c --- /dev/null +++ b/pkgs/http2/lib/src/settings/settings.dart @@ -0,0 +1,225 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import '../error_handler.dart'; +import '../frames/frames.dart'; +import '../hpack/hpack.dart'; +import '../sync_errors.dart'; + +/// The settings a remote peer can choose to set. +class ActiveSettings { + /// Allows the sender to inform the remote endpoint of the maximum size of the + /// header compression table used to decode header blocks, in octets. The + /// encoder can select any size equal to or less than this value by using + /// signaling specific to the header compression format inside a header block. + /// The initial value is 4,096 octets. + int headerTableSize; + + /// This setting can be use to disable server push (Section 8.2). An endpoint + /// MUST NOT send a PUSH_PROMISE frame if it receives this parameter set to a + /// value of 0. An endpoint that has both set this parameter to 0 and had it + /// acknowledged MUST treat the receipt of a PUSH_PROMISE frame as a + /// connection error (Section 5.4.1) of type PROTOCOL_ERROR. + /// + /// The initial value is 1, which indicates that server push is permitted. + /// Any value other than 0 or 1 MUST be treated as a connection error + /// (Section 5.4.1) of type PROTOCOL_ERROR. + bool enablePush; + + /// Indicates the maximum number of concurrent streams that the sender will + /// allow. This limit is directional: it applies to the number of streams that + /// the sender permits the receiver to create. Initially there is no limit to + /// this value. It is recommended that this value be no smaller than 100, so + /// as to not unnecessarily limit parallelism. + /// + /// A value of 0 for SETTINGS_MAX_CONCURRENT_STREAMS SHOULD NOT be treated as + /// special by endpoints. A zero value does prevent the creation of new + /// streams, however this can also happen for any limit that is exhausted with + /// active streams. Servers SHOULD only set a zero value for short durations; + /// if a server does not wish to accept requests, closing the connection is + /// more appropriate. + int? maxConcurrentStreams; + + /// Indicates the sender's initial window size (in octets) for stream level + /// flow control. The initial value is 2^16-1 (65,535) octets. + /// + /// This setting affects the window size of all streams, including existing + /// streams, see Section 6.9.2. + /// Values above the maximum flow control window size of 231-1 MUST be treated + /// as a connection error (Section 5.4.1) of type FLOW_CONTROL_ERROR. + int initialWindowSize; + + /// Indicates the size of the largest frame payload that the sender is willing + /// to receive, in octets. + /// + /// The initial value is 2^14 (16,384) octets. The value advertised by an + /// endpoint MUST be between this initial value and the maximum allowed frame + /// size (2^24-1 or 16,777,215 octets), inclusive. Values outside this range + /// MUST be treated as a connection error (Section 5.4.1) of type + /// PROTOCOL_ERROR. + int maxFrameSize; + + /// This advisory setting informs a peer of the maximum size of header list + /// that the sender is prepared to accept, in octets. The value is based on + /// the uncompressed size of header fields, including the length of the name + /// and value in octets plus an overhead of 32 octets for each header field. + /// + /// For any given request, a lower limit than what is advertised MAY be + /// enforced. The initial value of this setting is unlimited. + int? maxHeaderListSize; + + ActiveSettings( + {this.headerTableSize = 4096, + this.enablePush = true, + this.maxConcurrentStreams, + this.initialWindowSize = (1 << 16) - 1, + this.maxFrameSize = 1 << 14, + this.maxHeaderListSize}); +} + +/// Handles remote and local connection [Setting]s. +/// +/// Incoming [SettingsFrame]s will be handled here to update the peer settings. +/// Changes to [_toBeAcknowledgedSettings] can be made, the peer will then be +/// notified of the setting changes it should use. +class SettingsHandler extends Object with TerminatableMixin { + /// Certain settings changes can change the maximum allowed dynamic table + /// size used by the HPack encoder. + final HPackEncoder _hpackEncoder; + + final FrameWriter _frameWriter; + + /// A list of outstanding setting changes. + final List> _toBeAcknowledgedSettings = []; + + /// A list of completers for outstanding setting changes. + final List _toBeAcknowledgedCompleters = []; + + /// The local settings, which the remote side ACKed to obey. + final ActiveSettings _acknowledgedSettings; + + /// The peer settings, which we ACKed and are obeying. + final ActiveSettings _peerSettings; + + final _onInitialWindowSizeChangeController = + StreamController.broadcast(sync: true); + + /// Events are fired when a SettingsFrame changes the initial size + /// of stream windows. + Stream get onInitialWindowSizeChange => + _onInitialWindowSizeChangeController.stream; + + SettingsHandler(this._hpackEncoder, this._frameWriter, + this._acknowledgedSettings, this._peerSettings); + + /// The settings for this endpoint of the connection which the remote peer + /// has ACKed and uses. + ActiveSettings get acknowledgedSettings => _acknowledgedSettings; + + /// The settings for the remote endpoint of the connection which this + /// endpoint should use. + ActiveSettings get peerSettings => _peerSettings; + + /// Handles an incoming [SettingsFrame] which can be an ACK or a settings + /// change. + void handleSettingsFrame(SettingsFrame frame) { + ensureNotTerminatedSync(() { + assert(frame.header.streamId == 0); + + if (frame.hasAckFlag) { + assert(frame.header.length == 0); + + if (_toBeAcknowledgedSettings.isEmpty) { + // NOTE: The specification does not say anything about ACKed settings + // which were never sent to the other side. We consider this definitly + // an error. + throw ProtocolException( + 'Received an acknowledged settings frame which did not have a ' + 'outstanding settings request.'); + } + var settingChanges = _toBeAcknowledgedSettings.removeAt(0); + var completer = _toBeAcknowledgedCompleters.removeAt(0); + _modifySettings(_acknowledgedSettings, settingChanges, false); + completer.complete(); + } else { + _modifySettings(_peerSettings, frame.settings, true); + _frameWriter.writeSettingsAckFrame(); + } + }); + } + + @override + void onTerminated(Object? error) { + _toBeAcknowledgedSettings.clear(); + for (var completer in _toBeAcknowledgedCompleters) { + completer.completeError(error!); + } + } + + Future changeSettings(List changes) { + return ensureNotTerminatedAsync(() { + // TODO: Have a timeout: When ACK doesn't get back in a reasonable time + // frame we should quit with ErrorCode.SETTINGS_TIMEOUT. + var completer = Completer(); + _toBeAcknowledgedSettings.add(changes); + _toBeAcknowledgedCompleters.add(completer); + _frameWriter.writeSettingsFrame(changes); + return completer.future; + }); + } + + void _modifySettings( + ActiveSettings base, List changes, bool peerSettings) { + for (var setting in changes) { + switch (setting.identifier) { + case Setting.SETTINGS_ENABLE_PUSH: + if (setting.value == 0) { + base.enablePush = false; + } else if (setting.value == 1) { + base.enablePush = true; + } else { + throw ProtocolException( + 'The push setting can be only set to 0 or 1.'); + } + break; + + case Setting.SETTINGS_HEADER_TABLE_SIZE: + base.headerTableSize = setting.value; + if (peerSettings) { + _hpackEncoder.updateMaxSendingHeaderTableSize(base.headerTableSize); + } + break; + + case Setting.SETTINGS_MAX_HEADER_LIST_SIZE: + // TODO: Propagate this signal to the HPackContext. + base.maxHeaderListSize = setting.value; + break; + + case Setting.SETTINGS_MAX_CONCURRENT_STREAMS: + // NOTE: We will not force closing of existing streams if the limit is + // lower than the current number of open streams. But we will prevent + // new streams from being created if the number of existing streams + // is above this limit. + base.maxConcurrentStreams = setting.value; + break; + + case Setting.SETTINGS_INITIAL_WINDOW_SIZE: + if (setting.value < (1 << 31)) { + var difference = setting.value - base.initialWindowSize; + _onInitialWindowSizeChangeController.add(difference); + base.initialWindowSize = setting.value; + } else { + throw FlowControlException('Invalid initial window size.'); + } + break; + + default: + // Spec says to ignore unknown settings. + break; + } + } + } +} diff --git a/pkgs/http2/lib/src/streams/stream_handler.dart b/pkgs/http2/lib/src/streams/stream_handler.dart new file mode 100644 index 0000000000..92a228df5e --- /dev/null +++ b/pkgs/http2/lib/src/streams/stream_handler.dart @@ -0,0 +1,893 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import '../../transport.dart'; +import '../connection.dart'; +import '../error_handler.dart'; +import '../flowcontrol/connection_queues.dart'; +import '../flowcontrol/queue_messages.dart'; +import '../flowcontrol/stream_queues.dart'; +import '../flowcontrol/window.dart'; +import '../flowcontrol/window_handler.dart'; +import '../frames/frames.dart'; +import '../settings/settings.dart'; +import '../sync_errors.dart'; + +/// Represents the current state of a stream. +enum StreamState { + ReservedLocal, + ReservedRemote, + Idle, + Open, + HalfClosedLocal, + HalfClosedRemote, + Closed, + + /// The [Terminated] state is an artificial state and signals that this stream + /// has been forcefully terminated. + Terminated, +} + +/// Represents a HTTP/2 stream. +class Http2StreamImpl extends TransportStream + implements ClientTransportStream, ServerTransportStream { + /// The id of this stream. + /// + /// * odd numbered streams are client streams + /// * even numbered streams are opened from the server + @override + final int id; + + // The queue for incoming [StreamMessage]s. + final StreamMessageQueueIn incomingQueue; + + // The queue for outgoing [StreamMessage]s. + final StreamMessageQueueOut outgoingQueue; + + // The stream controller to which the application can + // add outgoing messages. + final StreamController _outgoingC; + + final OutgoingStreamWindowHandler windowHandler; + + // The state of this stream. + StreamState state = StreamState.Idle; + + // Error code from RST_STREAM frame, if the stream has been terminated + // remotely. + int? _terminatedErrorCode; + + // Termination handler. Invoked if the stream receives an RST_STREAM frame. + void Function(int)? _onTerminated; + + final ZoneUnaryCallback _canPushFun; + final ZoneBinaryCallback> + _pushStreamFun; + final ZoneUnaryCallback _terminateStreamFun; + + late StreamSubscription _outgoingCSubscription; + + Http2StreamImpl( + this.incomingQueue, + this.outgoingQueue, + this._outgoingC, + this.id, + this.windowHandler, + this._canPushFun, + this._pushStreamFun, + this._terminateStreamFun); + + /// A stream of data and/or headers from the remote end. + @override + Stream get incomingMessages => incomingQueue.messages; + + /// A sink for writing data and/or headers to the remote end. + @override + StreamSink get outgoingMessages => _outgoingC.sink; + + /// Streams which the server pushed to this endpoint. + @override + Stream get peerPushes => incomingQueue.serverPushes; + + @override + bool get canPush => _canPushFun(this); + + /// Pushes a new stream to a client. + /// + /// The [requestHeaders] are the headers to which the pushed stream + /// responds to. + @override + ServerTransportStream push(List
requestHeaders) => + _pushStreamFun(this, requestHeaders); + + @override + void terminate() => _terminateStreamFun(this); + + @override + set onTerminated(void Function(int) handler) { + _onTerminated = handler; + if (_terminatedErrorCode != null && _onTerminated != null) { + _onTerminated!(_terminatedErrorCode!); + } + } + + void _handleTerminated(int errorCode) { + _terminatedErrorCode = errorCode; + if (_onTerminated != null) { + _onTerminated!(_terminatedErrorCode!); + } + } +} + +/// Handles [Frame]s with a non-zero stream-id. +/// +/// It keeps track of open streams, their state, their queues, forwards +/// messages from the connection level to stream level and vise versa. +// TODO: Handle stream/connection queue errors & forward to connection object. +class StreamHandler extends Object with TerminatableMixin, ClosableMixin { + static const int MAX_STREAM_ID = (1 << 31) - 1; + + final FrameWriter _frameWriter; + final ConnectionMessageQueueIn incomingQueue; + final ConnectionMessageQueueOut outgoingQueue; + + final StreamController _newStreamsC = StreamController(); + + final ActiveSettings _peerSettings; + final ActiveSettings _localSettings; + + final Map _openStreams = {}; + int nextStreamId; + int lastRemoteStreamId; + + int _highestStreamIdReceived = 0; + + /// Represents the highest stream id this connection has received from the + /// remote side. + int get highestPeerInitiatedStream => _highestStreamIdReceived; + + bool get isServer => nextStreamId.isEven; + + bool get ranOutOfStreamIds => _ranOutOfStreamIds(); + + /// Whether it is possible to open a new stream to the remote end (e.g. based + /// on whether we have reached the limit of maximum concurrent open streams). + bool get canOpenStream => _canCreateNewStream(); + + final ActiveStateHandler _onActiveStateChanged; + + StreamHandler._( + this._frameWriter, + this.incomingQueue, + this.outgoingQueue, + this._peerSettings, + this._localSettings, + this._onActiveStateChanged, + this.nextStreamId, + this.lastRemoteStreamId); + + factory StreamHandler.client( + FrameWriter writer, + ConnectionMessageQueueIn incomingQueue, + ConnectionMessageQueueOut outgoingQueue, + ActiveSettings peerSettings, + ActiveSettings localSettings, + ActiveStateHandler onActiveStateChanged) { + return StreamHandler._(writer, incomingQueue, outgoingQueue, peerSettings, + localSettings, onActiveStateChanged, 1, 0); + } + + factory StreamHandler.server( + FrameWriter writer, + ConnectionMessageQueueIn incomingQueue, + ConnectionMessageQueueOut outgoingQueue, + ActiveSettings peerSettings, + ActiveSettings localSettings, + ActiveStateHandler onActiveStateChanged) { + return StreamHandler._(writer, incomingQueue, outgoingQueue, peerSettings, + localSettings, onActiveStateChanged, 2, -1); + } + + @override + void onTerminated(Object? error) { + _openStreams.values.toList().forEach((stream) => + _closeStreamAbnormally(stream, error, propagateException: true)); + startClosing(); + } + + void forceDispatchIncomingMessages() { + _openStreams.forEach((int streamId, Http2StreamImpl stream) { + stream.incomingQueue.forceDispatchIncomingMessages(); + }); + } + + Stream get incomingStreams => _newStreamsC.stream; + + List get openStreams => _openStreams.values.toList(); + + void processInitialWindowSizeSettingChange(int difference) { + // If the initialFlowWindow size was changed via a SettingsFrame, all + // existing streams must be updated to reflect this change. + for (var stream in _openStreams.values) { + stream.windowHandler.processInitialWindowSizeSettingChange(difference); + } + } + + void processGoawayFrame(GoawayFrame frame) { + var lastStreamId = frame.lastStreamId; + var streamIds = _openStreams.keys + .where((id) => id > lastStreamId && !_isPeerInitiatedStream(id)) + .toList(); + for (var id in streamIds) { + var exception = StreamException( + id, + 'Remote end was telling us to stop. This stream was not processed ' + 'and can therefore be retried (on a new connection).'); + _closeStreamIdAbnormally(id, exception, propagateException: true); + } + } + + //////////////////////////////////////////////////////////////////////////// + //// New local/remote Stream handling + //////////////////////////////////////////////////////////////////////////// + + bool _isPeerInitiatedStream(int streamId) { + var isServerStreamId = streamId.isEven; + var isLocalStream = isServerStreamId == isServer; + return !isLocalStream; + } + + Http2StreamImpl newStream(List
headers, {bool endStream = false}) { + return ensureNotTerminatedSync(() { + var stream = newLocalStream(); + _sendHeaders(stream, headers, endStream: endStream); + return stream; + }); + } + + Http2StreamImpl newLocalStream() { + return ensureNotTerminatedSync(() { + assert(_canCreateNewStream()); + + if (MAX_STREAM_ID < nextStreamId) { + throw StateError( + 'Cannot create new streams, since a wrap around would happen.'); + } + var streamId = nextStreamId; + nextStreamId += 2; + return _newStreamInternal(streamId); + }); + } + + Http2StreamImpl newRemoteStream(int remoteStreamId) { + return ensureNotTerminatedSync(() { + assert(remoteStreamId <= MAX_STREAM_ID); + // NOTE: We cannot enforce that a new stream id is 2 higher than the last + // used stream id. Meaning there can be "holes" in the sense that stream + // ids are not used: + // + // http/2 spec: + // The first use of a new stream identifier implicitly closes all + // streams in the "idle" state that might have been initiated by that + // peer with a lower-valued stream identifier. For example, if a client + // sends a HEADERS frame on stream 7 without ever sending a frame on + // stream 5, then stream 5 transitions to the "closed" state when the + // first frame for stream 7 is sent or received. + + if (remoteStreamId <= lastRemoteStreamId) { + throw ProtocolException('Remote tried to open new stream which is ' + 'not in "idle" state.'); + } + + var sameDirection = (nextStreamId + remoteStreamId).isEven; + assert(!sameDirection); + + lastRemoteStreamId = remoteStreamId; + return _newStreamInternal(remoteStreamId); + }); + } + + Http2StreamImpl _newStreamInternal(int streamId) { + // For each new stream we must: + // - setup sending/receiving [Window]s with correct initial size + // - setup sending/receiving WindowHandlers which take care of + // updating the windows. + // - setup incoming/outgoing stream queues, which buffer data + // that is not handled by + // * the application [incoming] + // * the underlying transport [outgoing] + // - register incoming stream queue in connection-level queue + + var outgoingStreamWindow = + Window(initialSize: _peerSettings.initialWindowSize); + + var incomingStreamWindow = + Window(initialSize: _localSettings.initialWindowSize); + + var windowOutHandler = OutgoingStreamWindowHandler(outgoingStreamWindow); + + var windowInHandler = IncomingWindowHandler.stream( + _frameWriter, incomingStreamWindow, streamId); + + var streamQueueIn = StreamMessageQueueIn(windowInHandler); + var streamQueueOut = + StreamMessageQueueOut(streamId, windowOutHandler, outgoingQueue); + + incomingQueue.insertNewStreamMessageQueue(streamId, streamQueueIn); + + var outgoingC = StreamController(); + var stream = Http2StreamImpl(streamQueueIn, streamQueueOut, outgoingC, + streamId, windowOutHandler, _canPush, _push, _terminateStream); + final wasIdle = _openStreams.isEmpty; + _openStreams[stream.id] = stream; + + _setupOutgoingMessageHandling(stream); + + // Handle incoming stream cancellation. RST is only sent when streamQueueOut + // has been closed because RST make the stream 'closed'. + streamQueueIn.onCancel.then((_) { + // If our side is done sending data, i.e. we have enqueued the + // end-of-stream in the outgoing message queue, but the remote end is + // still sending us data, despite us not being interested in it, we will + // reset the stream. + if (stream.state == StreamState.HalfClosedLocal) { + stream.outgoingQueue + .enqueueMessage(ResetStreamMessage(stream.id, ErrorCode.CANCEL)); + } + }); + + // NOTE: We are not interested whether the streams were normally finished + // or abnormally terminated. Therefore we use 'catchError((_) {})'! + var streamDone = [streamQueueIn.done, streamQueueOut.done]; + Future.wait(streamDone).catchError((_) => const []).whenComplete(() { + _cleanupClosedStream(stream); + }); + + if (wasIdle) { + _onActiveStateChanged(true); + } + + return stream; + } + + bool _canPush(Http2StreamImpl stream) { + var openState = stream.state == StreamState.Open || + stream.state == StreamState.HalfClosedRemote; + var pushEnabled = _peerSettings.enablePush; + return openState && + pushEnabled && + _canCreateNewStream() && + !_ranOutOfStreamIds(); + } + + ServerTransportStream _push( + Http2StreamImpl stream, List
requestHeaders) { + if (stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedRemote) { + throw StateError('Cannot push based on a stream that is neither open ' + 'nor half-closed-remote.'); + } + + if (!_peerSettings.enablePush) { + throw StateError('Client did disable server pushes.'); + } + + if (!_canCreateNewStream()) { + throw StateError('Maximum number of streams reached.'); + } + + if (_ranOutOfStreamIds()) { + throw StateError('There are no more stream ids left. Please use a ' + 'new connection.'); + } + + var pushStream = newLocalStream(); + + // NOTE: Since there was no real request from the client, we simulate it + // by adding a synthetic `endStream = true` Data message into the incoming + // queue. + _changeState(pushStream, StreamState.ReservedLocal); + // TODO: We should wait for us to send the headers frame before doing this + // transition. + _changeState(pushStream, StreamState.HalfClosedRemote); + pushStream.incomingQueue + .enqueueMessage(DataMessage(stream.id, const [], true)); + + _frameWriter.writePushPromiseFrame( + stream.id, pushStream.id, requestHeaders); + + return pushStream; + } + + void _terminateStream(Http2StreamImpl stream) { + if (stream.state == StreamState.Open || + stream.state == StreamState.HalfClosedLocal || + stream.state == StreamState.HalfClosedRemote || + stream.state == StreamState.ReservedLocal || + stream.state == StreamState.ReservedRemote) { + _frameWriter.writeRstStreamFrame(stream.id, ErrorCode.CANCEL); + _closeStreamAbnormally(stream, null, propagateException: false); + } + } + + void _setupOutgoingMessageHandling(Http2StreamImpl stream) { + stream._outgoingCSubscription = + stream._outgoingC.stream.listen((StreamMessage msg) { + if (!wasTerminated) { + _handleNewOutgoingMessage(stream, msg); + } + }, onError: (error, stack) { + if (!wasTerminated) { + stream.terminate(); + } + }, onDone: () { + if (!wasTerminated) { + // Stream should already have been closed by the last frame, but we + // allow multiple close calls, just to make sure. + _handleOutgoingClose(stream); + } + }); + stream.outgoingQueue.bufferIndicator.bufferEmptyEvents.listen((_) { + if (stream._outgoingCSubscription.isPaused) { + stream._outgoingCSubscription.resume(); + } + }); + } + + void _handleNewOutgoingMessage(Http2StreamImpl stream, StreamMessage msg) { + if (stream.state == StreamState.Idle) { + if (msg is! HeadersStreamMessage) { + var exception = TransportException( + 'The first message on a stream needs to be a headers frame.'); + _closeStreamAbnormally(stream, exception); + return; + } + _changeState(stream, StreamState.Open); + } + + if (msg is DataStreamMessage) { + _sendData(stream, msg.bytes, endStream: msg.endStream); + } else if (msg is HeadersStreamMessage) { + _sendHeaders(stream, msg.headers, endStream: msg.endStream); + } + + if (stream.outgoingQueue.bufferIndicator.wouldBuffer && + !stream._outgoingCSubscription.isPaused) { + stream._outgoingCSubscription.pause(); + } + } + + void _handleOutgoingClose(Http2StreamImpl stream) { + // We allow multiple close calls. + if (stream.state != StreamState.HalfClosedLocal && + stream.state != StreamState.Closed && + stream.state != StreamState.Terminated) { + _sendData(stream, const [], endStream: true); + } + } + + //////////////////////////////////////////////////////////////////////////// + //// Process incoming stream frames + //////////////////////////////////////////////////////////////////////////// + + void processStreamFrame(ConnectionState connectionState, Frame frame) { + try { + _processStreamFrameInternal(connectionState, frame); + } on StreamClosedException catch (exception) { + _frameWriter.writeRstStreamFrame( + exception.streamId, ErrorCode.STREAM_CLOSED); + _closeStreamIdAbnormally(exception.streamId, exception); + } on StreamException catch (exception) { + _frameWriter.writeRstStreamFrame( + exception.streamId, ErrorCode.INTERNAL_ERROR); + _closeStreamIdAbnormally(exception.streamId, exception); + } + } + + void _processStreamFrameInternal( + ConnectionState connectionState, Frame frame) { + // If we initiated a close of the connection and the received frame belongs + // to a stream id which is higher than the last peer-initiated stream we + // processed, we'll ignore it. + // http/2 spec: + // After sending a GOAWAY frame, the sender can discard frames for + // streams initiated by the receiver with identifiers higher than the + // identified last stream. However, any frames that alter connection + // state cannot be completely ignored. For instance, HEADERS, + // PUSH_PROMISE, and CONTINUATION frames MUST be minimally processed to + // ensure the state maintained for header compression is consistent + // (see Section 4.3); similarly, DATA frames MUST be counted toward + // the connection flow-control window. Failure to process these + // frames can cause flow control or header compression state to become + // unsynchronized. + if (connectionState.activeFinishing && + _isPeerInitiatedStream(frame.header.streamId) && + frame.header.streamId > highestPeerInitiatedStream) { + // Even if the frame will be ignored, we still need to process it in a + // minimal way to ensure the connection window will be updated. + if (frame is DataFrame) { + incomingQueue.processIgnoredDataFrame(frame); + } + return; + } + + // TODO: Consider splitting this method into client/server handling. + return ensureNotTerminatedSync(() { + var stream = _openStreams[frame.header.streamId]; + if (stream == null) { + bool frameBelongsToIdleStream() { + var streamId = frame.header.streamId; + var isServerStreamId = frame.header.streamId.isEven; + var isLocalStream = isServerStreamId == isServer; + var isIdleStream = isLocalStream + ? streamId >= nextStreamId + : streamId > lastRemoteStreamId; + return isIdleStream; + } + + if (_isPeerInitiatedStream(frame.header.streamId)) { + // Update highest stream id we received and processed (we update it + // before processing, so if it was an error, the client will not + // retry it). + _highestStreamIdReceived = + max(_highestStreamIdReceived, frame.header.streamId); + } + + if (frame is HeadersFrame) { + if (isServer) { + var newStream = newRemoteStream(frame.header.streamId); + _changeState(newStream, StreamState.Open); + + _handleHeadersFrame(newStream, frame); + _newStreamsC.add(newStream); + } else { + // A server cannot open new streams to the client. The only way + // for a server to start a new stream is via a PUSH_PROMISE_FRAME. + throw ProtocolException( + 'HTTP/2 clients cannot receive HEADER_FRAMEs as a connection' + 'attempt.'); + } + } else if (frame is WindowUpdateFrame) { + if (frameBelongsToIdleStream()) { + // We treat this as a protocol error even though not enforced + // or specified by the HTTP/2 spec. + throw ProtocolException( + 'Got a WINDOW_UPDATE_FRAME for an "idle" stream id.'); + } else { + // We must be able to receive window update frames for streams that + // have been already closed. The specification does not mention + // what happens if the streamId is belonging to an "idle" / unused + // stream. + } + } else if (frame is RstStreamFrame) { + if (frameBelongsToIdleStream()) { + // [RstFrame]s for streams which haven't been established (known as + // idle streams) must be treated as a connection error. + throw ProtocolException( + 'Got a RST_STREAM_FRAME for an "idle" stream id.'); + } else { + // [RstFrame]s for already dead (known as "closed") streams should + // be ignored. (If the stream was in "HalfClosedRemote" and we did + // send an endStream=true, it will be removed from the stream set). + } + } else if (frame is PriorityFrame) { + // http/2 spec: + // The PRIORITY frame can be sent for a stream in the "idle" or + // "closed" states. This allows for the reprioritization of a + // group of dependent streams by altering the priority of an + // unused or closed parent stream. + // + // As long as we do not handle stream priorities, we can safely ignore + // such frames on idle streams. + // + // NOTE: Firefox for example sends [PriorityFrame]s even without + // opening any streams (e.g. streams 3,5,7,9,11 [PriorityFrame]s and + // stream 13 is the first real stream opened by a [HeadersFrame]. + // + // TODO: When implementing priorities for HTTP/2 streams, these frames + // need to be taken into account. + } else if (frame is PushPromiseFrame) { + throw ProtocolException('Cannot push on a non-existent stream ' + '(stream ${frame.header.streamId} does not exist)'); + } else if (frame is DataFrame) { + // http/2 spec: + // However, after sending the RST_STREAM, the sending endpoint + // MUST be prepared to receive and process additional frames sent + // on the stream that might have been sent by the peer prior to + // the arrival of the RST_STREAM. + // and: + // A receiver that receives a flow-controlled frame MUST always + // account for its contribution against the connection + // flow-control window, unless the receiver treats this as a + // connection error (Section 5.4.1). This is necessary even if the + // frame is in error. The sender counts the frame toward the + // flow-control window, but if the receiver does not, the + // flow-control window at the sender and receiver can become + // different. + incomingQueue.processIgnoredDataFrame(frame); + // Still respond with an error, as the stream is closed. + throw _throwStreamClosedException(frame.header.streamId); + } else { + throw _throwStreamClosedException(frame.header.streamId); + } + } else { + if (frame is HeadersFrame) { + _handleHeadersFrame(stream, frame); + } else if (frame is DataFrame) { + _handleDataFrame(stream, frame); + } else if (frame is PushPromiseFrame) { + _handlePushPromiseFrame(stream, frame); + } else if (frame is WindowUpdateFrame) { + _handleWindowUpdate(stream, frame); + } else if (frame is RstStreamFrame) { + _handleRstFrame(stream, frame); + } else { + throw ProtocolException( + 'Unsupported frame type ${frame.runtimeType}.'); + } + } + }); + } + + void _handleHeadersFrame(Http2StreamImpl stream, HeadersFrame frame) { + if (stream.state == StreamState.ReservedRemote) { + _changeState(stream, StreamState.HalfClosedLocal); + } + + if (stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedLocal) { + throw StreamClosedException( + stream.id, 'Expected open state (was: ${stream.state}).'); + } + + incomingQueue.processHeadersFrame(frame); + + if (frame.hasEndStreamFlag) _handleEndOfStreamRemote(stream); + } + + void _handleDataFrame(Http2StreamImpl stream, DataFrame frame) { + if (stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedLocal) { + throw StreamClosedException( + stream.id, 'Expected open state (was: ${stream.state}).'); + } + + incomingQueue.processDataFrame(frame); + + if (frame.hasEndStreamFlag) _handleEndOfStreamRemote(stream); + } + + void _handlePushPromiseFrame(Http2StreamImpl stream, PushPromiseFrame frame) { + if (stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedLocal) { + throw ProtocolException('Expected open state (was: ${stream.state}).'); + } + + var pushedStream = newRemoteStream(frame.promisedStreamId); + _changeState(pushedStream, StreamState.ReservedRemote); + + incomingQueue.processPushPromiseFrame(frame, pushedStream); + } + + void _handleWindowUpdate(Http2StreamImpl stream, WindowUpdateFrame frame) { + stream.windowHandler.processWindowUpdate(frame); + } + + void _handleRstFrame(Http2StreamImpl stream, RstStreamFrame frame) { + stream._handleTerminated(frame.errorCode); + var exception = StreamTransportException( + 'Stream was terminated by peer (errorCode: ${frame.errorCode}).'); + _closeStreamAbnormally(stream, exception, propagateException: true); + } + + void _handleEndOfStreamRemote(Http2StreamImpl stream) { + if (stream.state == StreamState.Open) { + _changeState(stream, StreamState.HalfClosedRemote); + } else if (stream.state == StreamState.HalfClosedLocal) { + _changeState(stream, StreamState.Closed); + // TODO: We have to make sure that we + // - remove the stream for data structures which only care about the + // state + // - keep the stream in data structures which need to be emptied + // (e.g. MessageQueues which are not empty yet). + _openStreams.remove(stream.id); + } else { + throw StateError( + 'Got an end-of-stream from the remote end, but this stream is ' + 'neither in the Open nor in the HalfClosedLocal state. ' + 'This should never happen.'); + } + } + + //////////////////////////////////////////////////////////////////////////// + //// Process outgoing stream messages + //////////////////////////////////////////////////////////////////////////// + + void _sendHeaders(Http2StreamImpl stream, List
headers, + {bool endStream = false}) { + if (stream.state != StreamState.Idle && + stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedRemote) { + throw StateError('Idle state expected.'); + } + + stream.outgoingQueue + .enqueueMessage(HeadersMessage(stream.id, headers, endStream)); + + if (stream.state == StreamState.Idle) { + _changeState(stream, StreamState.Open); + } + + if (endStream) { + _endStream(stream); + } + } + + void _sendData(Http2StreamImpl stream, List data, + {bool endStream = false}) { + if (stream.state != StreamState.Open && + stream.state != StreamState.HalfClosedRemote) { + throw StateError('Open state expected (was: ${stream.state}).'); + } + + stream.outgoingQueue + .enqueueMessage(DataMessage(stream.id, data, endStream)); + + if (endStream) { + _endStream(stream); + } + } + + void _endStream(Http2StreamImpl stream) { + if (stream.state == StreamState.Open) { + _changeState(stream, StreamState.HalfClosedLocal); + } else if (stream.state == StreamState.HalfClosedRemote) { + _changeState(stream, StreamState.Closed); + } else { + throw StateError('Invalid state transition. This should never happen.'); + } + } + + //////////////////////////////////////////////////////////////////////////// + //// Stream closing + //////////////////////////////////////////////////////////////////////////// + + void _cleanupClosedStream(Http2StreamImpl stream) { + // NOTE: This function should only be called once + // * all incoming data has been delivered to the application + // * all outgoing data has been added to the connection queue. + incomingQueue.removeStreamMessageQueue(stream.id); + _openStreams.remove(stream.id); + if (stream.state != StreamState.Terminated) { + _changeState(stream, StreamState.Terminated); + } + if (_openStreams.isEmpty) { + _onActiveStateChanged(false); + } + onCheckForClose(); + } + + void _closeStreamIdAbnormally(int streamId, Exception exception, + {bool propagateException = false}) { + var stream = _openStreams[streamId]; + if (stream != null) { + _closeStreamAbnormally(stream, exception, + propagateException: propagateException); + } + } + + void _closeStreamAbnormally(Http2StreamImpl stream, Object? exception, + {bool propagateException = false}) { + incomingQueue.removeStreamMessageQueue(stream.id); + + if (stream.state != StreamState.Terminated) { + _changeState(stream, StreamState.Terminated); + } + stream.incomingQueue.terminate(propagateException ? exception : null); + stream._outgoingCSubscription.cancel(); + stream._outgoingC.close(); + + // NOTE: we're not adding an error here. + stream.outgoingQueue.terminate(); + + onCheckForClose(); + } + + @override + void onClosing() { + _newStreamsC.close(); + } + + @override + void onCheckForClose() { + if (isClosing && _openStreams.isEmpty) { + closeWithValue(); + } + } + + //////////////////////////////////////////////////////////////////////////// + //// State transitioning & Counting of active streams + //////////////////////////////////////////////////////////////////////////// + + /// The number of streams which we initiated and which are in one of the open + /// states (i.e. [StreamState.Open], [StreamState.HalfClosedLocal] or + /// [StreamState.HalfClosedRemote]) + int _numberOfActiveStreams = 0; + + bool _canCreateNewStream() { + var limit = _peerSettings.maxConcurrentStreams; + return limit == null || _numberOfActiveStreams < limit; + } + + bool _ranOutOfStreamIds() { + return nextStreamId > MAX_STREAM_ID; + } + + void _changeState(Http2StreamImpl stream, StreamState to) { + var from = stream.state; + + // In checked mode we'll test that the state transition is allowed. + assert((from == StreamState.Idle && to == StreamState.ReservedLocal) || + (from == StreamState.Idle && to == StreamState.ReservedRemote) || + (from == StreamState.Idle && to == StreamState.Open) || + (from == StreamState.Open && to == StreamState.HalfClosedLocal) || + (from == StreamState.Open && to == StreamState.HalfClosedRemote) || + (from == StreamState.Open && to == StreamState.Closed) || + (from == StreamState.HalfClosedLocal && to == StreamState.Closed) || + (from == StreamState.HalfClosedRemote && to == StreamState.Closed) || + (from == StreamState.ReservedLocal && + to == StreamState.HalfClosedRemote) || + (from == StreamState.ReservedLocal && to == StreamState.Closed) || + (from == StreamState.ReservedRemote && to == StreamState.Closed) || + (from == StreamState.ReservedRemote && + to == StreamState.HalfClosedLocal) || + (from != StreamState.Terminated && to == StreamState.Terminated)); + + // If we initiated the stream and it became "open" or "closed" we need to + // update the [_numberOfActiveStreams] counter. + if (_didInitiateStream(stream)) { + // NOTE: We wait until the stream is completely done. + // (If we waited only until `StreamState.Closed` then we might still have + // the endStream header/data message buffered, but not yet sent out). + switch (stream.state) { + case StreamState.ReservedLocal: + case StreamState.ReservedRemote: + case StreamState.Idle: + if (to == StreamState.Open || + to == StreamState.HalfClosedLocal || + to == StreamState.HalfClosedRemote) { + _numberOfActiveStreams++; + } + break; + case StreamState.Open: + case StreamState.HalfClosedLocal: + case StreamState.HalfClosedRemote: + case StreamState.Closed: + if (to == StreamState.Terminated) { + _numberOfActiveStreams--; + } + break; + case StreamState.Terminated: + // There is nothing to do here. + break; + } + } + stream.state = to; + } + + bool _didInitiateStream(Http2StreamImpl stream) { + var id = stream.id; + return (isServer && id.isEven) || (!isServer && id.isOdd); + } + + static Exception _throwStreamClosedException(int streamId) => + StreamClosedException( + streamId, + 'No open stream found and was not a headers frame opening a ' + 'new stream.', + ); +} diff --git a/pkgs/http2/lib/src/sync_errors.dart b/pkgs/http2/lib/src/sync_errors.dart new file mode 100644 index 0000000000..3d11616ad1 --- /dev/null +++ b/pkgs/http2/lib/src/sync_errors.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +class ProtocolException implements Exception { + final String _message; + + ProtocolException(this._message); + + @override + String toString() => 'ProtocolError: $_message'; +} + +class FlowControlException implements Exception { + final String _message; + + FlowControlException(this._message); + + @override + String toString() => 'FlowControlException: $_message'; +} + +class FrameSizeException implements Exception { + final String _message; + + FrameSizeException(this._message); + + @override + String toString() => 'FrameSizeException: $_message'; +} + +class TerminatedException implements Exception { + @override + String toString() => 'TerminatedException: The object has been terminated.'; +} + +class StreamException implements Exception { + final String _message; + final int streamId; + + StreamException(this.streamId, this._message); + + @override + String toString() => 'StreamException(stream id: $streamId): $_message'; +} + +class StreamClosedException extends StreamException { + StreamClosedException(super.streamId, [super.message = '']); + + @override + String toString() => 'StreamClosedException(stream id: $streamId): $_message'; +} diff --git a/pkgs/http2/lib/transport.dart b/pkgs/http2/lib/transport.dart new file mode 100644 index 0000000000..87a10f6799 --- /dev/null +++ b/pkgs/http2/lib/transport.dart @@ -0,0 +1,243 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'src/connection.dart'; +import 'src/hpack/hpack.dart' show Header; + +export 'src/frames/frames.dart' show ErrorCode; +export 'src/hpack/hpack.dart' show Header; + +typedef ActiveStateHandler = void Function(bool isActive); + +/// Settings for a [TransportConnection]. +abstract class Settings { + /// The maximum number of concurrent streams the remote end can open + /// (defaults to being unlimited). + final int? concurrentStreamLimit; + + /// The default stream window size the remote peer can use when creating new + /// streams (defaults to 65535 bytes). + final int? streamWindowSize; + + const Settings({this.concurrentStreamLimit, this.streamWindowSize}); +} + +/// Settings for a [TransportConnection] a server can make. +class ServerSettings extends Settings { + const ServerSettings({super.concurrentStreamLimit, super.streamWindowSize}); +} + +/// Settings for a [TransportConnection] a client can make. +class ClientSettings extends Settings { + /// Whether the client allows pushes from the server (defaults to false). + final bool allowServerPushes; + + const ClientSettings( + {super.concurrentStreamLimit, + super.streamWindowSize, + this.allowServerPushes = false}); +} + +/// Represents a HTTP/2 connection. +abstract class TransportConnection { + /// Pings the other end. + Future ping(); + + /// Sets the active state callback. + /// + /// This callback is invoked with `true` when the number of active streams + /// goes from 0 to 1 (the connection goes from idle to active), and with + /// `false` when the number of active streams becomes 0 (the connection goes + /// from active to idle). + set onActiveStateChanged(ActiveStateHandler callback); + + /// Future which completes when the first SETTINGS frame is received from + /// the peer. + Future get onInitialPeerSettingsReceived; + + /// Stream which emits an event with the ping id every time a ping is received + /// on this connection. + Stream get onPingReceived; + + /// Stream which emits an event every time a ping is received on this + /// connection. + Stream get onFrameReceived; + + /// Finish this connection. + /// + /// No new streams will be accepted or can be created. + Future finish(); + + /// Terminates this connection forcefully. + Future terminate([int? errorCode]); +} + +abstract class ClientTransportConnection extends TransportConnection { + factory ClientTransportConnection.viaSocket(Socket socket, + {ClientSettings? settings}) => + ClientTransportConnection.viaStreams(socket, socket, settings: settings); + + factory ClientTransportConnection.viaStreams( + Stream> incoming, StreamSink> outgoing, + {ClientSettings? settings}) { + settings ??= const ClientSettings(); + return ClientConnection(incoming, outgoing, settings); + } + + /// Whether this connection is open and can be used to make new requests + /// via [makeRequest]. + bool get isOpen; + + /// Creates a new outgoing stream. + ClientTransportStream makeRequest(List
headers, + {bool endStream = false}); +} + +abstract class ServerTransportConnection extends TransportConnection { + factory ServerTransportConnection.viaSocket(Socket socket, + {ServerSettings? settings}) { + return ServerTransportConnection.viaStreams(socket, socket, + settings: settings); + } + + factory ServerTransportConnection.viaStreams( + Stream> incoming, StreamSink> outgoing, + {ServerSettings? settings = + const ServerSettings(concurrentStreamLimit: 1000)}) { + settings ??= const ServerSettings(); + return ServerConnection(incoming, outgoing, settings); + } + + /// Incoming HTTP/2 streams. + Stream get incomingStreams; +} + +/// Represents a HTTP/2 stream. +abstract class TransportStream { + /// The id of this stream. + /// + /// * odd numbered streams are client streams + /// * even numbered streams are opened from the server + int get id; + + /// A stream of data and/or headers from the remote end. + Stream get incomingMessages; + + /// A sink for writing data and/or headers to the remote end. + StreamSink get outgoingMessages; + + /// Sets the termination handler on this stream. + /// + /// The handler will be called if the stream receives an RST_STREAM frame. + set onTerminated(void Function(int?) value); + + /// Terminates this HTTP/2 stream in an un-normal way. + /// + /// For normal termination, one can cancel the [StreamSubscription] from + /// `incoming.listen()` and close the `outgoing` [StreamSink]. + /// + /// Terminating this HTTP/2 stream will free up all resources associated with + /// it locally and will notify the remote end that this stream is no longer + /// used. + void terminate(); + + // For convenience only. + void sendHeaders(List
headers, {bool endStream = false}) { + outgoingMessages.add(HeadersStreamMessage(headers, endStream: endStream)); + if (endStream) outgoingMessages.close(); + } + + void sendData(List bytes, {bool endStream = false}) { + outgoingMessages.add(DataStreamMessage(bytes, endStream: endStream)); + if (endStream) outgoingMessages.close(); + } +} + +abstract class ClientTransportStream extends TransportStream { + /// Streams which the remote end pushed to this endpoint. + /// + /// If peer pushes were enabled, the client is responsible to either + /// handle or reject any peer push. + Stream get peerPushes; +} + +abstract class ServerTransportStream extends TransportStream { + /// Whether a method to [push] will succeed. Requirements for this getter to + /// return `true` are: + /// * this stream must be in the Open or HalfClosedRemote state + /// * the client needs to have the "enable push" settings enabled + /// * the number of active streams has not reached the maximum + bool get canPush; + + /// Pushes a new stream to the remote peer. + ServerTransportStream push(List
requestHeaders); +} + +/// Represents a message which can be sent over a HTTP/2 stream. +abstract class StreamMessage { + final bool endStream; + + StreamMessage({bool? endStream}) : endStream = endStream ?? false; +} + +/// Represents a data message which can be sent over a HTTP/2 stream. +class DataStreamMessage extends StreamMessage { + final List bytes; + + DataStreamMessage(this.bytes, {super.endStream}); + + @override + String toString() => 'DataStreamMessage(${bytes.length} bytes)'; +} + +/// Represents a headers message which can be sent over a HTTP/2 stream. +class HeadersStreamMessage extends StreamMessage { + final List
headers; + + HeadersStreamMessage(this.headers, {super.endStream}); + + @override + String toString() => 'HeadersStreamMessage(${headers.length} headers)'; +} + +/// Represents a remote stream push. +class TransportStreamPush { + /// The request headers which [stream] is the response to. + final List
requestHeaders; + + /// The remote stream push. + final ClientTransportStream stream; + + TransportStreamPush(this.requestHeaders, this.stream); + + @override + String toString() => + 'TransportStreamPush(${requestHeaders.length} request headers headers)'; +} + +/// An exception thrown by the HTTP/2 implementation. +class TransportException implements Exception { + final String message; + + TransportException(this.message); + + @override + String toString() => 'HTTP/2 error: $message'; +} + +/// An exception thrown when a HTTP/2 connection error occurred. +class TransportConnectionException extends TransportException { + final int errorCode; + + TransportConnectionException(this.errorCode, String details) + : super('Connection error: $details (errorCode: $errorCode)'); +} + +/// An exception thrown when a HTTP/2 stream error occured. +class StreamTransportException extends TransportException { + StreamTransportException(String details) : super('Stream error: $details'); +} diff --git a/pkgs/http2/manual_test/out_of_stream_ids_test.dart b/pkgs/http2/manual_test/out_of_stream_ids_test.dart new file mode 100644 index 0000000000..acfbbd9cee --- /dev/null +++ b/pkgs/http2/manual_test/out_of_stream_ids_test.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE FILE. + +/// --------------------------------------------------------------------------- +/// In order to run this test one needs to change the following line in +/// ../lib/src/streams/stream_handler.dart +/// +/// - static const int MAX_STREAM_ID = (1 << 31) - 1; +/// + static const int MAX_STREAM_ID = (1 << 5) - 1; +/// +/// without this patch this test will run for a _long_ time. +/// --------------------------------------------------------------------------- +library; + +import 'dart:async'; + +import 'package:http2/src/streams/stream_handler.dart'; +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import '../test/transport_test.dart'; + +void main() { + group('transport-test', () { + transportTest('client-runs-out-of-stream-ids', + (ClientTransportConnection client, + ServerTransportConnection server) async { + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.sendHeaders([Header.ascii('x', 'y')], endStream: true); + expect(await stream.incomingMessages.toList(), hasLength(1)); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + + const kMaxStreamId = StreamHandler.MAX_STREAM_ID; + for (var i = 1; i <= kMaxStreamId; i += 2) { + var stream = client.makeRequest(headers, endStream: true); + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + } + + expect(client.isOpen, false); + expect(() => client.makeRequest(headers), + throwsA(const TypeMatcher())); + + await Future.delayed(const Duration(seconds: 1)); + await client.finish(); + } + + var serverFuture = serverFun(); + var clientFuture = clientFun(); + + await serverFuture; + await clientFuture; + }); + }); +} diff --git a/pkgs/http2/pubspec.yaml b/pkgs/http2/pubspec.yaml new file mode 100644 index 0000000000..7d9e6b851b --- /dev/null +++ b/pkgs/http2/pubspec.yaml @@ -0,0 +1,18 @@ +name: http2 +version: 2.3.1 +description: A HTTP/2 implementation in Dart. +repository: https://github.com/dart-lang/http/tree/master/pkgs/http2 + +topics: + - http + - network + - protocols + +environment: + sdk: ^3.2.0 + +dev_dependencies: + build_runner: ^2.3.0 + dart_flutter_team_lints: ^2.0.0 + mockito: ^5.3.2 + test: ^1.21.4 diff --git a/pkgs/http2/test/certificates/server_chain.pem b/pkgs/http2/test/certificates/server_chain.pem new file mode 100644 index 0000000000..4163fe7ddd --- /dev/null +++ b/pkgs/http2/test/certificates/server_chain.pem @@ -0,0 +1,57 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIJAOWmjTS+OnTEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV +BAMMDGludGVybWVkaWF0ZTAeFw0xNTA1MTgwOTAwNDBaFw0yMzA4MDQwOTAwNDBa +MBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALlcwQJuzd+xH8QFgfJSn5tRlvhkldSX98cE7NiA602NBbnAVyUrkRXq +Ni75lgt0kwjYfA9z674m8WSVbgpLPintPCla9CYky1TH0keIs8Rz6cGWHryWEHiu +EDuljQynu2b3sAFuHu9nfWurbJwZnFakBKpdQ9m4EyOZCHC/jHYY7HacKSXg1Cki +we2ca0BWDrcqy8kLy0dZ5oC6IZG8O8drAK8f3f44CRYw59D3sOKBrKXaabpvyEcb +N7Wk2HDBVwHpUJo1reVwtbM8dhqQayYSD8oXnGpP3RQNu/e2rzlXRyq/BfcDY1JI +7TbC4t/7/N4EcPSpGsTcSOC9A7FpzvECAwEAAaN7MHkwCQYDVR0TBAIwADAsBglg +hkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0O +BBYEFCnwiEMMFZh7NhCr+qA8K0w4Q+AOMB8GA1UdIwQYMBaAFB0h1Evsaw2vfrmS +YuoCTmC4EE6ZMA0GCSqGSIb3DQEBCwUAA4IBAQAcFmHMaXRxyoNaeOowQ6iQWoZd +AUbvG7SHr7I6Pi2aqdqofsKWts7Ytm5WsS0M2nN+sW504houu0iCPeJJX8RQw2q4 +CCcNOs9IXk+2uMzlpocHpv+yYoUiD5DxgWh7eghQMLyMpf8FX3Gy4VazeuXznHOM +4gE4L417xkDzYOzqVTp0FTyAPUv6G2euhNCD6TMru9REcRhYul+K9kocjA5tt2KG +MH6y28LXbLyq4YJUxSUU9gY/xlnbbZS48KDqEcdYC9zjW9nQ0qS+XQuQuFIcwjJ5 +V4kAUYxDu6FoTpyQjgsrmBbZlKNxH7Nj4NDlcdJhp/zeSKHqWa5hSWjjKIxp +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAjCCAeqgAwIBAgIJAOWmjTS+OnTDMA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAXMRUwEwYDVQQDDAxpbnRlcm1lZGlhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDSrAO1CoPvUllgLOzDm5nG0skDF7vh1DUgAIDVGz0ecD0JFbQx +EF79pju/6MbtpTW2FYvRp11t/G7rGtX923ybOHY/1MNFQrdIvPlO1VV7IGKjoMwP +DNeb0fIGjHoE9QxaDxR8NX8xQbItpsw+TUtRfc9SLkR+jaYJfVRoM21BOncZbSHE +YKiZlEbpecB/+EtwVpgvl+8mPD5U07Fi4fp/lza3WXInXQPyiTVllIEJCt4PKmlu +MocNaJOW38bysL7i0PzDpVZtOxLHOTaW68yF3FckIHNCaA7k1ABEEEegjFMmIao7 +B9w7A0jvr4jZVvNmui5Djjn+oJxwEVVgyf8LAgMBAAGjUDBOMB0GA1UdDgQWBBQd +IdRL7GsNr365kmLqAk5guBBOmTAfBgNVHSMEGDAWgBRk81s9d0ZbiZhh44KckwPb +oTc0XzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBZQTK0plfdB5PC +cC5icut4EmrByJa1RbU7ayuEE70e7hla6KVmVjVdCBGltI4jBYwfhKbRItHiAJ/8 +x+XZKBG8DLPFuDb7lAa1ObhAYF7YThUFPQYaBhfzKcWrdmWDBFpvNv6E0Mm364dZ +e7Yxmbe5S4agkYPoxEzgEYmcUk9jbjdR6eTbs8laG169ljrECXfEU9RiAcqz5iSX +NLSewqB47hn3B9qgKcQn+PsgO2j7M+rfklhNgeGJeWmy7j6clSOuCsIjWHU0RLQ4 +0W3SB/rpEAJ7fgQbYUPTIUNALSOWi/o1tDX2mXPRjBoxqAv7I+vYk1lZPmSzkyRh +FKvRDxsW +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIJAJ0MomS4Ck+8MA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV +BAMMDXJvb3RhdXRob3JpdHkwHhcNMTUwNTE4MDkwMDQwWhcNMjMwODA0MDkwMDQw +WjAYMRYwFAYDVQQDDA1yb290YXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAts1ijtBV92S2cOvpUMOSTp9c6A34nIGr0T5Nhz6XiqRVT+gv +dQgmkdKJQjbvR60y6jzltYFsI2MpGVXY8h/oAL81D/k7PDB2aREgyBfTPAhBHyGw +siR+2xYt5b/Zs99q5RdRqQNzNpLPJriIKvUsRyQWy1UiG2s7pRXQeA8qB0XtJdCj +kFIi+G2bDsaffspGeDOCqt7t+yqvRXfSES0c/l7DIHaiMbbp4//ZNML3RNgAjPz2 +hCezZ+wOYajOIyoSPK8IgICrhYFYxvgWxwbLDBEfC5B3jOQsySe10GoRAKZz1gBV +DmgReu81tYJmdgkc9zknnQtIFdA0ex+GvZlfWQIDAQABo1AwTjAdBgNVHQ4EFgQU +ZPNbPXdGW4mYYeOCnJMD26E3NF8wHwYDVR0jBBgwFoAUZPNbPXdGW4mYYeOCnJMD +26E3NF8wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATzkZ97K777uZ +lQcduNX3ey4IbCiEzFA2zO5Blj+ilfIwNbZXNOgm/lqNvVGDYs6J1apJJe30vL3X +J+t2zsZWzzQzb9uIU37zYemt6m0fHrSrx/iy5lGNqt3HMfqEcOqSCOIK3PCTMz2/ +uyGe1iw33PVeWsm1JUybQ9IrU/huJjbgOHU4wab+8SJCM49ipArp68Fr6j4lcEaE +4rfRg1ZsvxiOyUB3qPn6wyL/JB8kOJ+QCBe498376eaem8AEFk0kQRh6hDaWtq/k +t6IIXQLjx+EBDVP/veK0UnVhKRP8YTOoV8ZiG1NcdlJmX/Uk7iAfevP7CkBfSN8W +r6AL284qtw== +-----END CERTIFICATE----- diff --git a/pkgs/http2/test/certificates/server_key.pem b/pkgs/http2/test/certificates/server_key.pem new file mode 100644 index 0000000000..1fd2324045 --- /dev/null +++ b/pkgs/http2/test/certificates/server_key.pem @@ -0,0 +1,29 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIE5DAcBgoqhkiG9w0BDAEBMA4ECL7L6rj6uEHGAgIIAASCBMLbucyfqAkgCbhP +xNSHYllPMAv/dsIjtnsBwepCXPGkCBCuOAw/2FaCHjN9hBqL5V7fkrKeaemhm2YE +ycPtlHJYPDf3kEkyMjdZ9rIY6kePGfQizs2uJPcXj4YPyQ4HsfVXpOicKfQrouf5 +Mze9bGzeMN065q3iP4dYUMwHAyZYteXCsanQNHlqvsWli0W+H8St8fdsXefZhnv1 +qVatKWdNdWQ9t5MuljgNU2Vv56sHKEYXI0yLxk2QUMk8KlJfnmt8foYUsnPUXHmc +gIjLKwwVkpdololnEHSNu0cEOUPowjgJru+uMpn7vdNl7TPEQ9jbEgdNg4JwoYzU +0nao8WzjaSp7kzvZz0VFwKnk5AjstGvvuAWckADdq23QElbn/mF7AG1m/TBpYxzF +gTt37UdndS/AcvVznWVVrRP5iTSIawdIwvqI4s7rqsoE0GCcak+RhchgAz2gWKkS +oODUo0JL6pPVbJ3l4ebbaO6c99nDVc8dViPtc1EkStJEJ2O4kI4xgLSCr4Y9ahKn +oAaoSkX7Xxq3aQm+BzqSpLjdGL8atsqR/YVOIHYIl3gThvP0NfZGx1xHyvO5mCdZ +kHxSA7tKWxauZ3eQ2clbnzeRsl4El0WMHy/5K1ovene4v7sunmoXVtghBC8hK6eh +zMO9orex2PNQ/VQC7HCvtytunOVx1lkSBoNo7hR70igg6rW9H7UyoAoBOwMpT1xa +J6V62nqruTKOqFNfur7aHJGpHGtDb5/ickHeYCyPTvmGp67u4wChzKReeg02oECe +d1E5FKAcIa8s9TVOB6Z+HvTRNQZu2PsI6TJnjQRowvY9DAHiWTlJZBBY/pko3hxX +TsIeybpvRdEHpDWv86/iqtw1hv9CUxS/8ZTWUgBo+osShHW79FeDASr9FC4/Zn76 +ZDERTgV4YWlW/klVWcG2lFo7jix+OPXAB+ZQavLhlN1xdWBcIz1AUWjAM4hdPylW +HCX4PB9CQIPl2E7F+Y2p6nMcMWSJVBi5UIH7E9LfaBguXSzMmTk2Fw5p1aOQ6wfN +goVAMVwi8ppAVs741PfHdZ295xMmK/1LCxz5DeAdD/tsA/SYfT753GotioDuC7im +EyJ5JyvTr5I6RFFBuqt3NlUb3Hp16wP3B2x9DZiB6jxr0l341/NHgsyeBXkuIy9j +ON2mvpBPCJhS8kgWo3G0UyyKnx64tcgpGuSvZhGwPz843B6AbYyE6pMRfSWRMkMS +YZYa+VNKhR4ixdj07ocFZEWLVjCH7kxkE8JZXKt8jKYmkWd0lS1QVjgaKlO6lRa3 +q6SPJkhW6pvqobvcqVNXwi1XuzpZeEbuh0B7OTekFTTxx5g9XeDl56M8SVQ1KEhT +Q1t7H2Nba18WCB7cf+6PN0F0K0Jz1Kq7ZWaqEI/grX1m4RQuvNF5807sB/QKMO/Z +Gz3NXvHg5xTJRd/567lxPGkor0cE7qD1EZfmJ2HrBYXQ91bhgA7LToBuMZo6ZRXH +QfsanjbP4FPLMiGdQigLjj3A35L/f4sQOOVac/sRaFnm7pzcxsMvyVU/YtvGcjYE +xaOOVnamg661Wo0wksXoDjeSz/JIyyKO3Gwp1FSm2wGLjjy/Ehmqcqy8rvHuf07w +AUukhVtTNn4= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/pkgs/http2/test/client_test.dart b/pkgs/http2/test/client_test.dart new file mode 100644 index 0000000000..726707f357 --- /dev/null +++ b/pkgs/http2/test/client_test.dart @@ -0,0 +1,881 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show ascii; +import 'dart:typed_data'; + +import 'package:http2/src/connection_preface.dart'; +import 'package:http2/src/flowcontrol/window.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/hpack/hpack.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import 'src/hpack/hpack_test.dart' show isHeader; + +void main() { + group('client-tests', () { + group('normal', () { + clientTest('gracefull-shutdown-for-unused-connection', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var settingsDone = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + settingsDone.complete(); + + // Make sure we get the graceful shutdown message. + expect( + await nextFrame(), + isA() + .having((f) => f.errorCode, 'errorCode', ErrorCode.NO_ERROR)); + + // Make sure the client ended the connection. + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + await settingsDone.future; + + expect(client.isOpen, true); + + // Try to gracefully finish the connection. + var future = client.finish(); + + expect(client.isOpen, false); + + await future; + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + + group('connection-operational', () { + clientTest('on-connection-operational-fires', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + final settingsDone = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + settingsDone.complete(); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + // Make sure we get the graceful shutdown message. + expect( + await nextFrame(), + isA() + .having((f) => f.errorCode, 'errorCode', ErrorCode.NO_ERROR)); + + // Make sure the client ended the connection. + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + await settingsDone.future; + await client.onInitialPeerSettingsReceived + .timeout(const Duration(milliseconds: 20)); // Should complete + + expect(client.isOpen, true); + + // Try to gracefully finish the connection. + var future = client.finish(); + + expect(client.isOpen, false); + + await future; + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('on-connection-operational-does-not-fire', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + final goawayReceived = Completer(); + Future serverFun() async { + serverWriter.writePingFrame(42); + expect(await nextFrame(), isA()); + expect(await nextFrame(), isA()); + goawayReceived.complete(); + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + expect(client.isOpen, true); + + expect( + client.onInitialPeerSettingsReceived + .timeout(const Duration(seconds: 1)), + throwsA(isA())); + + // We wait until the server received the error (it's actually later + // than necessary, but we can't make a deterministic test otherwise). + await goawayReceived.future; + + expect(client.isOpen, false); + + String? error; + try { + client.makeRequest([Header.ascii('a', 'b')]); + } catch (e) { + error = '$e'; + } + expect(error, contains('no longer active')); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + + group('server-errors', () { + clientTest('no-settings-frame-at-beginning-immediate-error', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var goawayReceived = Completer(); + Future serverFun() async { + serverWriter.writePingFrame(42); + expect(await nextFrame(), isA()); + expect(await nextFrame(), isA()); + goawayReceived.complete(); + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + expect(client.isOpen, true); + + // We wait until the server received the error (it's actually later + // than necessary, but we can't make a deterministic test otherwise). + await goawayReceived.future; + + expect(client.isOpen, false); + + String? error; + try { + client.makeRequest([Header.ascii('a', 'b')]); + } catch (e) { + error = '$e'; + } + expect(error, contains('no longer active')); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('no-settings-frame-at-beginning-delayed-error', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + Future serverFun() async { + expect(await nextFrame(), isA()); + expect(await nextFrame(), isA()); + serverWriter.writePingFrame(42); + expect(await nextFrame(), isA()); + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + expect(client.isOpen, true); + var stream = client.makeRequest([Header.ascii('a', 'b')]); + + String? error; + try { + await stream.incomingMessages.toList(); + } catch (e) { + error = '$e'; + } + expect(error, contains('forcefully terminated')); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('data-frame-for-invalid-stream', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + expect( + await nextFrame(), + isA().having( + (p0) => p0.hasEndStreamFlag, 'Last data frame', true)); + + // Write a data frame for a non-existent stream. + var invalidStreamId = headers.header.streamId + 2; + serverWriter.writeDataFrame(invalidStreamId, [42]); + + // Make sure the client sends a [RstStreamFrame] frame. + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0)); + expect( + await nextFrame(), + isA() + .having( + (f) => f.errorCode, 'errorCode', ErrorCode.STREAM_CLOSED) + .having((f) => f.header.streamId, 'header.streamId', + invalidStreamId)); + + // Close the original stream. + serverWriter.writeDataFrame(headers.header.streamId, [], + endStream: true); + + // Wait for the client finish. + expect(await nextFrame(), isA()); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + await stream.outgoingMessages.close(); + expect(await stream.incomingMessages.toList(), isEmpty); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('data-frame-after-stream-closed', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + expect( + await nextFrame(), + isA().having( + (p0) => p0.hasEndStreamFlag, 'Last data frame', true)); + + var streamId = headers.header.streamId; + + // Write a data frame for a non-existent stream. + var data1 = [42, 42]; + serverWriter.writeDataFrame(streamId, data1, endStream: true); + + // Write more data on the closed stream. + var data2 = [42]; + serverWriter.writeDataFrame(streamId, data2); + + // NOTE: The order of the window update frame / rst frame just + // happens to be like that ATM. + + // The two WindowUpdateFrames for the data1 DataFrame. + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Stream update', 1) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', + data1.length)); + + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', + data1.length)); + + // The [WindowUpdateFrame] for the frame on the closed stream, which + // should still update the connection. + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', + data2.length)); + + // Make sure we get a [RstStreamFrame] frame. + expect( + await nextFrame(), + isA() + .having( + (f) => f.errorCode, 'errorCode', ErrorCode.STREAM_CLOSED) + .having( + (f) => f.header.streamId, 'header.streamId', streamId)); + + // Wait for the client finish. + expect(await nextFrame(), isA()); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + await stream.outgoingMessages.close(); + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + expect( + messages[0], + isA() + .having((p0) => p0.bytes, 'Same as `data1` above', [42, 42]), + ); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('data-frame-received-after-stream-cancel', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + var cancelDone = Completer(); + var endDone = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + expect( + await nextFrame(), + isA().having( + (p0) => p0.hasEndStreamFlag, 'Last data frame', true)); + var streamId = headers.header.streamId; + + // Write a data frame. + serverWriter.writeDataFrame(streamId, [42]); + await cancelDone.future; + serverWriter.writeDataFrame(streamId, [43]); + + // NOTE: The order of the window update frame / rst frame just + // happens to be like that ATM. + + // Await stream/connection window update frame. + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Stream update', 1) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + + // Make sure we get a [RstStreamFrame] frame. + expect( + await nextFrame(), + isA() + .having((f) => f.errorCode, 'errorCode', ErrorCode.CANCEL) + .having( + (f) => f.header.streamId, 'header.streamId', streamId)); + + serverWriter.writeRstStreamFrame(streamId, ErrorCode.STREAM_CLOSED); + + endDone.complete(); + + // Wait for the client finish. + expect(await nextFrame(), isA()); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + await stream.outgoingMessages.close(); + + // first will cancel the stream + var message = await stream.incomingMessages.first; + expect( + message, + isA() + .having((p0) => p0.bytes, 'Same sent data above', [42]), + ); + + cancelDone.complete(); + + await endDone.future; + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('data-frame-received-after-stream-cancel-and-out-not-closed', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + var cancelDone = Completer(); + var endDone = Completer(); + var clientDone = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + + var streamId = headers.header.streamId; + + // Write a data frame. + serverWriter.writeDataFrame(streamId, [42]); + await cancelDone.future; + serverWriter.writeDataFrame(streamId, [43]); + serverWriter.writeRstStreamFrame(streamId, ErrorCode.STREAM_CLOSED); + endDone.complete(); + + // NOTE: The order of the window update frame / rst frame just + // happens to be like that ATM. + + // Await stream/connection window update frame. + + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Stream update', 1) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + expect( + await nextFrame(), + isA() + .having((p0) => p0.header.streamId, 'Connection update', 0) + .having((p0) => p0.windowSizeIncrement, 'Windowsize', 1)); + + await clientDone.future; + expect( + await nextFrame(), + isA().having( + (p0) => p0.hasEndStreamFlag, 'Last data frame', true)); + + // Wait for the client finish. + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + + // first will cancel the stream + var message = await stream.incomingMessages.first; + expect( + message, + isA() + .having((p0) => p0.bytes, 'Same sent data above', [42]), + ); + + cancelDone.complete(); + + await endDone.future; + + await stream.outgoingMessages.close(); + clientDone.complete(); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('client-reports-connection-error-on-push-to-nonexistent', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + expect( + await nextFrame(), + isA().having( + (p0) => p0.hasEndStreamFlag, 'Last data frame', true)); + + var streamId = headers.header.streamId; + + // Write response. + serverWriter.writeHeadersFrame(streamId, [Header.ascii('a', 'b')], + endStream: true); + + // Push stream to the (non existing) one. + var pushStreamId = 2; + serverWriter.writePushPromiseFrame( + streamId, pushStreamId, [Header.ascii('a', 'b')]); + + // Make sure we get a connection error. + var frame = await nextFrame() as GoawayFrame; + expect(ascii.decode(frame.debugData), + contains('Cannot push on a non-existent stream')); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + await stream.outgoingMessages.close(); + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + + expect( + messages[0], + isA().having((p0) => p0.headers.first, + 'Same sent headers above', isHeader('a', 'b')), + ); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('client-reports-connection-error-on-push-to-non-open', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + var streamId = headers.header.streamId; + + // Write response. + serverWriter.writeDataFrame(streamId, [], endStream: true); + + // Push stream onto the existing (but half-closed) one. + var pushStreamId = 2; + serverWriter.writePushPromiseFrame( + streamId, pushStreamId, [Header.ascii('a', 'b')]); + + // Make sure we get a connection error. + var frame = await nextFrame() as GoawayFrame; + expect( + ascii.decode(frame.debugData), + contains( + 'Expected open state (was: StreamState.HalfClosedRemote)')); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + + // NOTE: We are not closing the outgoing part on purpose. + expect(await stream.incomingMessages.toList(), isEmpty); + expect(await stream.peerPushes.toList(), isEmpty); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('client-reports-flowcontrol-error-on-negative-window', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var handshakeCompleter = Completer(); + + Future serverFun() async { + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + handshakeCompleter.complete(); + + var headers = await nextFrame() as HeadersFrame; + var streamId = headers.header.streamId; + + // Write more than [kFlowControlWindowSize] bytes. + final kFlowControlWindowSize = Window().size; + var sentBytes = 0; + final bytes = Uint8List(1024); + while (sentBytes <= kFlowControlWindowSize) { + serverWriter.writeDataFrame(streamId, bytes); + sentBytes += bytes.length; + } + + // Read the resulting [GoawayFrame] and assert the error message + // describes that the flow control window became negative. + var frame = await nextFrame() as GoawayFrame; + expect( + ascii.decode(frame.debugData), + contains('Connection level flow control window became ' + 'negative.')); + expect(await serverReader.moveNext(), false); + await serverWriter.close(); + } + + Future clientFun() async { + await handshakeCompleter.future; + + var stream = client.makeRequest([Header.ascii('a', 'b')]); + var sub = stream.incomingMessages.listen( + expectAsync1((StreamMessage msg) {}, count: 0), + onError: expectAsync1((Object error) {})); + sub.pause(); + await Future.delayed(const Duration(milliseconds: 40)); + sub.resume(); + + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + + group('client-errors', () { + clientTest('client-resets-stream', (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var settingsDone = Completer(); + var headersDone = Completer(); + + Future serverFun() async { + var decoder = HPackDecoder(); + + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + settingsDone.complete(); + + // Make sure we got the new stream. + var frame = await nextFrame() as HeadersFrame; + expect(frame.hasEndStreamFlag, false); + var decodedHeaders = decoder.decode(frame.headerBlockFragment); + expect(decodedHeaders, hasLength(1)); + expect(decodedHeaders[0], isHeader('a', 'b')); + + headersDone.complete(); + + // Make sure we got the stream reset. + expect( + await nextFrame(), + isA().having( + (p0) => p0.errorCode, 'Stream reset', ErrorCode.CANCEL)); + + // Make sure we get the graceful shutdown message. + expect( + await nextFrame(), + isA().having( + (p0) => p0.errorCode, 'Stream reset', ErrorCode.NO_ERROR)); + + // Make sure the client ended the connection. + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + await settingsDone.future; + + // Make a new stream and terminate it. + var stream = + client.makeRequest([Header.ascii('a', 'b')], endStream: false); + + await headersDone.future; + stream.terminate(); + + // Make sure we don't get messages/pushes on the terminated stream. + expect(await stream.incomingMessages.toList(), isEmpty); + expect(await stream.peerPushes.toList(), isEmpty); + + // Try to gracefully finish the connection. + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + clientTest('goaway-terminates-nonprocessed-streams', + (ClientTransportConnection client, + FrameWriter serverWriter, + StreamIterator serverReader, + Future Function() nextFrame) async { + var settingsDone = Completer(); + + Future serverFun() async { + var decoder = HPackDecoder(); + + serverWriter.writeSettingsFrame([]); + expect(await nextFrame(), isA()); + serverWriter.writeSettingsAckFrame(); + expect(await nextFrame(), isA()); + + settingsDone.complete(); + + // Make sure we got the new stream. + var frame = await nextFrame() as HeadersFrame; + expect(frame.hasEndStreamFlag, false); + var decodedHeaders = decoder.decode(frame.headerBlockFragment); + expect(decodedHeaders, hasLength(1)); + expect(decodedHeaders[0], isHeader('a', 'b')); + + // Send the GoawayFrame. + serverWriter.writeGoawayFrame(0, ErrorCode.NO_ERROR, []); + + // Since there are no open streams left, the other end should just + // close the connection. + expect(await serverReader.moveNext(), false); + } + + Future clientFun() async { + await settingsDone.future; + + // Make a new stream and terminate it. + var stream = + client.makeRequest([Header.ascii('a', 'b')], endStream: false); + + // Make sure we don't get messages/pushes on the terminated stream. + unawaited( + stream.incomingMessages.toList().catchError(expectAsync1((e) { + expect( + '$e', + contains('This stream was not processed and can ' + 'therefore be retried')); + return []; + }))); + expect(await stream.peerPushes.toList(), isEmpty); + + // Try to gracefully finish the connection. + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + }); +} + +void clientTest( + String name, + Future Function(ClientTransportConnection, FrameWriter, + StreamIterator frameReader, Future Function() readNext) + func, +) { + return test(name, () { + var streams = ClientStreams(); + var serverReader = streams.serverConnectionFrameReader; + + Future readNext() async { + expect(await serverReader.moveNext(), true); + return serverReader.current; + } + + return func(streams.clientConnection, streams.serverConnectionFrameWriter, + serverReader, readNext); + }); +} + +class ClientStreams { + final StreamController> writeA = StreamController(); + final StreamController> writeB = StreamController(); + Stream> get readA => writeA.stream; + Stream> get readB => writeB.stream; + + StreamIterator get serverConnectionFrameReader { + var localSettings = ActiveSettings(); + var streamAfterConnectionPreface = readConnectionPreface(readA); + return StreamIterator( + FrameReader(streamAfterConnectionPreface, localSettings) + .startDecoding()); + } + + FrameWriter get serverConnectionFrameWriter { + var encoder = HPackEncoder(); + var peerSettings = ActiveSettings(); + return FrameWriter(encoder, writeB, peerSettings); + } + + ClientTransportConnection get clientConnection => + ClientTransportConnection.viaStreams(readB, writeA); +} diff --git a/pkgs/http2/test/multiprotocol_server_test.dart b/pkgs/http2/test/multiprotocol_server_test.dart new file mode 100644 index 0000000000..736e7da6b3 --- /dev/null +++ b/pkgs/http2/test/multiprotocol_server_test.dart @@ -0,0 +1,130 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show ascii, utf8; +import 'dart:io'; + +import 'package:http2/multiprotocol_server.dart'; +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +void main() { + var context = SecurityContext() + ..useCertificateChain('test/certificates/server_chain.pem') + ..usePrivateKey('test/certificates/server_key.pem', password: 'dartdart'); + + group('multiprotocol-server', () { + test('http/1.1', () async { + const Count = 2; + + var server = await MultiProtocolHttpServer.bind('localhost', 0, context); + var requestNr = 0; + server.startServing( + expectAsync1((HttpRequest request) async { + await handleHttp11Request(request, requestNr++); + if (requestNr == Count) { + await server.close(); + } + }, count: Count), + expectAsync1((ServerTransportStream stream) {}, count: 0)); + + var client = HttpClient(); + client.badCertificateCallback = (_, __, ___) => true; + for (var i = 0; i < Count; i++) { + await makeHttp11Request(server, client, i); + } + }); + + test('http/2', () async { + const Count = 2; + + var server = await MultiProtocolHttpServer.bind('localhost', 0, context); + var requestNr = 0; + server.startServing( + expectAsync1((HttpRequest request) {}, count: 0), + expectAsync1((ServerTransportStream stream) async { + await handleHttp2Request(stream, requestNr++); + if (requestNr == Count) { + await server.close(); + } + }, count: Count)); + + var socket = await SecureSocket.connect('localhost', server.port, + onBadCertificate: (_) => true, + supportedProtocols: ['http/1.1', 'h2']); + var connection = ClientTransportConnection.viaSocket(socket); + for (var i = 0; i < Count; i++) { + await makeHttp2Request(server, connection, i); + } + await connection.finish(); + }); + }); +} + +Future makeHttp11Request( + MultiProtocolHttpServer server, HttpClient client, int i) async { + var request = + await client.getUrl(Uri.parse('https://localhost:${server.port}/abc$i')); + var response = await request.close(); + var body = await response.cast>().transform(utf8.decoder).join(''); + expect(body, 'answer$i'); +} + +Future handleHttp11Request(HttpRequest request, int i) async { + expect(request.uri.path, '/abc$i'); + await request.drain(); + request.response.write('answer$i'); + await request.response.close(); +} + +Future makeHttp2Request(MultiProtocolHttpServer server, + ClientTransportConnection connection, int i) async { + expect(connection.isOpen, true); + var headers = [ + Header.ascii(':method', 'GET'), + Header.ascii(':scheme', 'https'), + Header.ascii(':authority', 'localhost:${server.port}'), + Header.ascii(':path', '/abc$i'), + ]; + + var stream = connection.makeRequest(headers, endStream: true); + var si = StreamIterator(stream.incomingMessages); + + expect(await si.moveNext(), true); + expect(si.current, isA()); + var responseHeaders = getHeaders(si.current as HeadersStreamMessage); + expect(responseHeaders[':status'], '200'); + + expect(await si.moveNext(), true); + expect(ascii.decode((si.current as DataStreamMessage).bytes), 'answer$i'); + + expect(await si.moveNext(), false); +} + +Future handleHttp2Request(ServerTransportStream stream, int i) async { + var si = StreamIterator(stream.incomingMessages); + + expect(await si.moveNext(), true); + expect(si.current, isA()); + var headers = getHeaders(si.current as HeadersStreamMessage); + + expect(headers[':path'], '/abc$i'); + expect(await si.moveNext(), false); + + stream.outgoingMessages.add(HeadersStreamMessage([ + Header.ascii(':status', '200'), + ])); + + stream.outgoingMessages.add(DataStreamMessage(ascii.encode('answer$i'))); + await stream.outgoingMessages.close(); +} + +Map getHeaders(HeadersStreamMessage headers) { + var map = {}; + for (var h in headers.headers) { + map.putIfAbsent(ascii.decode(h.name), () => ascii.decode(h.value)); + } + return map; +} diff --git a/pkgs/http2/test/server_test.dart b/pkgs/http2/test/server_test.dart new file mode 100644 index 0000000000..1af51c929a --- /dev/null +++ b/pkgs/http2/test/server_test.dart @@ -0,0 +1,244 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/connection_preface.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/hpack/hpack.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +void main() { + group('server-tests', () { + group('normal', () { + serverTest('gracefull-shutdown-for-unused-connection', + (ServerTransportConnection server, + FrameWriter clientWriter, + StreamIterator clientReader, + Future Function() nextFrame) async { + Future serverFun() async { + expect(await server.incomingStreams.toList(), isEmpty); + await server.finish(); + } + + Future clientFun() async { + expect(await nextFrame() is SettingsFrame, true); + clientWriter.writeSettingsAckFrame(); + clientWriter.writeSettingsFrame([]); + expect(await nextFrame() is SettingsFrame, true); + + // Tell the server to finish. + clientWriter.writeGoawayFrame(3, ErrorCode.NO_ERROR, []); + + // Make sure the server ended the connection. + expect(await clientReader.moveNext(), false); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + + group('client-errors', () { + serverTest('no-settings-frame-at-beginning', + (ServerTransportConnection server, + FrameWriter clientWriter, + StreamIterator clientReader, + Future Function() nextFrame) async { + Future serverFun() async { + // TODO: Do we want to get an error in this case? + expect(await server.incomingStreams.toList(), isEmpty); + await server.finish(); + } + + Future clientFun() async { + expect(await nextFrame() is SettingsFrame, true); + + // Write headers frame to open a new stream + clientWriter.writeHeadersFrame(1, [], endStream: true); + + // Make sure the client gets a [GoawayFrame] frame. + expect( + await nextFrame(), + isA().having( + (f) => f.errorCode, 'errorCode', ErrorCode.PROTOCOL_ERROR)); + + // Make sure the server ended the connection. + expect(await clientReader.moveNext(), false); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + serverTest('data-frame-for-invalid-stream', + (ServerTransportConnection server, + FrameWriter clientWriter, + StreamIterator clientReader, + Future Function() nextFrame) async { + Future serverFun() async { + await server.incomingStreams.toList(); + await server.finish(); + } + + Future clientFun() async { + expect(await nextFrame() is SettingsFrame, true); + clientWriter.writeSettingsAckFrame(); + clientWriter.writeSettingsFrame([]); + expect(await nextFrame() is SettingsFrame, true); + + // Write data frame to non-existent stream. + clientWriter.writeDataFrame(3, [1, 2, 3]); + + // Make sure the client gets a [RstStreamFrame] frame. + var frame = await nextFrame(); + expect(frame is WindowUpdateFrame, true); + expect( + await nextFrame(), + isA() + .having( + (f) => f.errorCode, 'errorCode', ErrorCode.STREAM_CLOSED) + .having((f) => f.header.streamId, 'header.streamId', 3)); + + // Tell the server to finish. + clientWriter.writeGoawayFrame(3, ErrorCode.NO_ERROR, []); + + // Make sure the server ended the connection. + expect(await clientReader.moveNext(), false); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + serverTest('data-frame-after-stream-closed', + (ServerTransportConnection server, + FrameWriter clientWriter, + StreamIterator clientReader, + Future Function() nextFrame) async { + Future serverFun() async { + await server.incomingStreams.toList(); + await server.finish(); + } + + Future clientFun() async { + expect(await nextFrame() is SettingsFrame, true); + clientWriter.writeSettingsAckFrame(); + clientWriter.writeSettingsFrame([]); + expect(await nextFrame() is SettingsFrame, true); + + clientWriter.writeHeadersFrame(3, [Header.ascii('a', 'b')], + endStream: true); + + // Write data frame to non-existent stream (stream 3 was closed + // above). + clientWriter.writeDataFrame(3, [1, 2, 3]); + + // Make sure the client gets a [RstStreamFrame] frame. + expect( + await nextFrame(), + isA() + .having( + (f) => f.errorCode, 'errorCode', ErrorCode.STREAM_CLOSED) + .having((f) => f.header.streamId, 'header.streamId', 3)); + + // Tell the server to finish. + clientWriter.writeGoawayFrame(3, ErrorCode.NO_ERROR, []); + + // Make sure the server ended the connection. + expect(await clientReader.moveNext(), false); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + + group('server-errors', () { + serverTest('server-resets-stream', (ServerTransportConnection server, + FrameWriter clientWriter, + StreamIterator clientReader, + Future Function() nextFrame) async { + Future serverFun() async { + var it = StreamIterator(server.incomingStreams); + expect(await it.moveNext(), true); + + TransportStream stream = it.current; + stream.terminate(); + + expect(await it.moveNext(), false); + + await server.finish(); + } + + Future clientFun() async { + expect(await nextFrame() is SettingsFrame, true); + clientWriter.writeSettingsAckFrame(); + clientWriter.writeSettingsFrame([]); + expect(await nextFrame() is SettingsFrame, true); + + clientWriter.writeHeadersFrame(1, [Header.ascii('a', 'b')], + endStream: false); + + // Make sure the client gets a [RstStreamFrame] frame. + expect( + await nextFrame(), + isA() + .having((f) => f.errorCode, 'errorCode', ErrorCode.CANCEL) + .having((f) => f.header.streamId, 'header.streamId', 1)); + + // Tell the server to finish. + clientWriter.writeGoawayFrame(3, ErrorCode.NO_ERROR, []); + + // Make sure the server ended the connection. + expect(await clientReader.moveNext(), false); + } + + await Future.wait([serverFun(), clientFun()]); + }); + }); + }); +} + +void serverTest( + String name, + void Function( + ServerTransportConnection, + FrameWriter, + StreamIterator frameReader, + Future Function() readNext) + func) { + return test(name, () { + var streams = ClientErrorStreams(); + var clientReader = streams.clientConnectionFrameReader; + + Future readNext() async { + expect(await clientReader.moveNext(), true); + return clientReader.current; + } + + return func(streams.serverConnection, streams.clientConnectionFrameWriter, + clientReader, readNext); + }); +} + +class ClientErrorStreams { + final StreamController> writeA = StreamController(); + final StreamController> writeB = StreamController(); + Stream> get readA => writeA.stream; + Stream> get readB => writeB.stream; + + StreamIterator get clientConnectionFrameReader { + var localSettings = ActiveSettings(); + return StreamIterator(FrameReader(readA, localSettings).startDecoding()); + } + + FrameWriter get clientConnectionFrameWriter { + var encoder = HPackEncoder(); + var peerSettings = ActiveSettings(); + writeB.add(CONNECTION_PREFACE); + return FrameWriter(encoder, writeB, peerSettings); + } + + ServerTransportConnection get serverConnection => + ServerTransportConnection.viaStreams(readB, writeA); +} diff --git a/pkgs/http2/test/src/async_utils/async_utils_test.dart b/pkgs/http2/test/src/async_utils/async_utils_test.dart new file mode 100644 index 0000000000..6d1ebaadd5 --- /dev/null +++ b/pkgs/http2/test/src/async_utils/async_utils_test.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/async_utils/async_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('async_utils', () { + test('buffer-indicator', () { + var bi = BufferIndicator(); + bi.bufferEmptyEvents.listen(expectAsync1((_) {}, count: 2)); + + expect(bi.wouldBuffer, true); + + bi.markUnBuffered(); + expect(bi.wouldBuffer, false); + + bi.markBuffered(); + expect(bi.wouldBuffer, true); + bi.markBuffered(); + expect(bi.wouldBuffer, true); + + bi.markUnBuffered(); + expect(bi.wouldBuffer, false); + bi.markUnBuffered(); + expect(bi.wouldBuffer, false); + + bi.markBuffered(); + expect(bi.wouldBuffer, true); + bi.markBuffered(); + expect(bi.wouldBuffer, true); + }); + + test('buffered-sink', () { + var c = StreamController>(); + var bs = BufferedSink(c); + + expect(bs.bufferIndicator.wouldBuffer, true); + var sub = c.stream.listen(expectAsync1((_) {}, count: 2)); + + expect(bs.bufferIndicator.wouldBuffer, false); + + sub.pause(); + Timer.run(expectAsync0(() { + expect(bs.bufferIndicator.wouldBuffer, true); + bs.sink.add([1]); + + sub.resume(); + Timer.run(expectAsync0(() { + expect(bs.bufferIndicator.wouldBuffer, false); + bs.sink.add([2]); + + Timer.run(expectAsync0(() { + sub.cancel(); + expect(bs.bufferIndicator.wouldBuffer, false); + })); + })); + })); + }); + + test('buffered-bytes-writer', () async { + var c = StreamController>(); + var writer = BufferedBytesWriter(c); + + expect(writer.bufferIndicator.wouldBuffer, true); + + var bytesFuture = c.stream.fold>([], (b, d) => b..addAll(d)); + + expect(writer.bufferIndicator.wouldBuffer, false); + + writer.add([1, 2]); + writer.add([3, 4]); + + writer.addBufferedData([5, 6]); + expect(() => writer.add([7, 8]), throwsStateError); + + writer.addBufferedData([7, 8]); + await writer.close(); + expect(await bytesFuture, [1, 2, 3, 4, 5, 6, 7, 8]); + }); + }); +} diff --git a/pkgs/http2/test/src/connection_preface_test.dart b/pkgs/http2/test/src/connection_preface_test.dart new file mode 100644 index 0000000000..d2e6eb3f61 --- /dev/null +++ b/pkgs/http2/test/src/connection_preface_test.dart @@ -0,0 +1,80 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:math' show min; + +import 'package:http2/src/connection_preface.dart'; +import 'package:test/test.dart'; + +void main() { + group('connection-preface', () { + test('successful', () async { + final frameBytes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + final data = List.from(CONNECTION_PREFACE)..addAll(frameBytes); + + for (var size = 1; size <= data.length; size++) { + var c = StreamController>(); + var resultF = readConnectionPreface(c.stream) + .fold>([], (b, d) => b..addAll(d)); + + for (var i = 0; i < (size - 1 + data.length) ~/ size; i++) { + var from = size * i; + var to = min(size * (i + 1), data.length); + + c.add(data.sublist(from, to)); + } + unawaited(c.close()); + + expect(await resultF, frameBytes); + } + }); + + test('only-part-of-connection-sequence', () async { + var c = StreamController>(); + var resultF = readConnectionPreface(c.stream) + .fold>([], (b, d) => b..addAll(d)); + + for (var i = 0; i < CONNECTION_PREFACE.length - 1; i++) { + c.add([CONNECTION_PREFACE[i]]); + } + unawaited(c.close()); + + unawaited(resultF.catchError(expectAsync2((Object error, Object _) { + expect(error, contains('EOS before connection preface could be read')); + return []; + }))); + }); + + test('wrong-connection-sequence', () async { + var c = StreamController>(); + var resultF = readConnectionPreface(c.stream) + .fold>([], (b, d) => b..addAll(d)); + + for (var i = 0; i < CONNECTION_PREFACE.length; i++) { + c.add([0xff]); + } + unawaited(c.close()); + + unawaited(resultF.catchError(expectAsync2((Object error, Object _) { + expect(error, contains('Connection preface does not match.')); + return []; + }))); + }); + + test('incoming-socket-error', () async { + var c = StreamController>(); + var resultF = readConnectionPreface(c.stream) + .fold>([], (b, d) => b..addAll(d)); + + c.addError('hello world'); + unawaited(c.close()); + + unawaited(resultF.catchError(expectAsync2((Object error, Object _) { + expect(error, contains('hello world')); + return []; + }))); + }); + }); +} diff --git a/pkgs/http2/test/src/error_matchers.dart b/pkgs/http2/test/src/error_matchers.dart new file mode 100644 index 0000000000..7886462525 --- /dev/null +++ b/pkgs/http2/test/src/error_matchers.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/sync_errors.dart'; +import 'package:test/test.dart'; + +const Matcher isProtocolException = TypeMatcher(); +const Matcher isFrameSizeException = TypeMatcher(); +const Matcher isTerminatedException = TypeMatcher(); +const Matcher isFlowControlException = TypeMatcher(); diff --git a/pkgs/http2/test/src/flowcontrol/connection_queues_test.dart b/pkgs/http2/test/src/flowcontrol/connection_queues_test.dart new file mode 100644 index 0000000000..1cde81be6a --- /dev/null +++ b/pkgs/http2/test/src/flowcontrol/connection_queues_test.dart @@ -0,0 +1,170 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/async_utils/async_utils.dart'; +import 'package:http2/src/flowcontrol/connection_queues.dart'; +import 'package:http2/src/flowcontrol/queue_messages.dart'; +import 'package:http2/src/flowcontrol/window.dart'; +import 'package:http2/src/flowcontrol/window_handler.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/transport.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'mocks.mocks.dart'; + +void main() { + group('flowcontrol', () { + test('connection-message-queue-out', () { + var fw = MockFrameWriter(); + when(fw.bufferIndicator).thenReturn(BufferIndicator()); + when(fw.writeHeadersFrame(any, any, endStream: anyNamed('endStream'))) + .thenReturn(null); + when(fw.writeDataFrame(any, any, endStream: anyNamed('endStream'))) + .thenReturn(null); + var windowMock = MockOutgoingWindowHandler(); + var queue = ConnectionMessageQueueOut(windowMock, fw); + + fw.bufferIndicator.markUnBuffered(); + + expect(queue.pendingMessages, 0); + + var headers = [Header.ascii('a', 'b')]; + var bytes = [1, 2, 3]; + + // Send [HeadersMessage]. + queue.enqueueMessage(HeadersMessage(99, headers, false)); + expect(queue.pendingMessages, 0); + verify(fw.writeHeadersFrame(99, headers, endStream: false)).called(1); + verify(fw.bufferIndicator).called(greaterThan(0)); + verifyNoMoreInteractions(fw); + verifyZeroInteractions(windowMock); + + clearInteractions(fw); + + // Send [DataMessage]. + windowMock.peerWindowSize = bytes.length; + windowMock.positiveWindow.markUnBuffered(); + queue.enqueueMessage(DataMessage(99, bytes, false)); + expect(queue.pendingMessages, 0); + verify(windowMock.decreaseWindow(bytes.length)).called(1); + verify(fw.writeDataFrame(99, bytes, endStream: false)).called(1); + verifyNoMoreInteractions(windowMock); + verify(fw.bufferIndicator).called(greaterThan(0)); + verifyNoMoreInteractions(fw); + + clearInteractions(fw); + clearInteractions(windowMock); + + // Send [DataMessage] if the connection window is too small. + // Should trigger fragmentation and should write 1 byte. + windowMock.peerWindowSize = 1; + // decreaseWindow() marks the window as buffered in this case, so we need + // our mock to do the same (otherwise, the call to markUnBuffered() below + // has no effect). + when(windowMock.decreaseWindow(1)).thenAnswer((_) { + windowMock.positiveWindow.markBuffered(); + }); + queue.enqueueMessage(DataMessage(99, bytes, true)); + expect(queue.pendingMessages, 1); + verify(windowMock.decreaseWindow(1)).called(1); + verify(fw.bufferIndicator).called(greaterThan(0)); + verify(fw.writeDataFrame(99, bytes.sublist(0, 1), endStream: false)) + .called(1); + verifyNoMoreInteractions(windowMock); + verifyNoMoreInteractions(fw); + + clearInteractions(fw); + reset(windowMock); + + // Now mark it as unbuffered. This should write the rest of the + // [bytes.length - 1] bytes. + windowMock.peerWindowSize = bytes.length - 1; + windowMock.positiveWindow.markUnBuffered(); + verify(windowMock.decreaseWindow(bytes.length - 1)).called(1); + verify(fw.writeDataFrame(99, bytes.sublist(1), endStream: true)) + .called(1); + verifyNoMoreInteractions(windowMock); + verify(fw.bufferIndicator).called(greaterThan(0)); + verifyNoMoreInteractions(fw); + + queue.startClosing(); + queue.done.then(expectAsync1((_) { + expect(queue.pendingMessages, 0); + expect(() => queue.enqueueMessage(DataMessage(99, bytes, true)), + throwsA(const TypeMatcher())); + })); + }); + + test('connection-message-queue-in', () { + const STREAM_ID = 99; + final bytes = [1, 2, 3]; + + var windowMock = MockIncomingWindowHandler(); + when(windowMock.gotData(any)).thenReturn(null); + when(windowMock.dataProcessed(any)).thenReturn(null); + + var queue = ConnectionMessageQueueIn(windowMock, (f) => f()); + expect(queue.pendingMessages, 0); + + var streamQueueMock = MockStreamMessageQueueIn(); + when(streamQueueMock.bufferIndicator).thenReturn(BufferIndicator()); + when(streamQueueMock.enqueueMessage(any)).thenReturn(null); + + queue.insertNewStreamMessageQueue(STREAM_ID, streamQueueMock); + + // Insert a [DataFrame] and let it be buffered. + var header = FrameHeader(0, 0, 0, STREAM_ID); + queue.processDataFrame(DataFrame(header, 0, bytes)); + expect(queue.pendingMessages, 1); + verify(windowMock.gotData(bytes.length)).called(1); + verifyNoMoreInteractions(windowMock); + verify(streamQueueMock.bufferIndicator).called(greaterThan(0)); + verifyNoMoreInteractions(streamQueueMock); + + clearInteractions(windowMock); + + // Indicate that the stream queue has space, and make sure + // the data is propagated from the connection to the stream + // specific queue. + streamQueueMock.bufferIndicator.markUnBuffered(); + verify(windowMock.dataProcessed(bytes.length)).called(1); + var capturedMessage = verify(streamQueueMock.enqueueMessage(captureAny)) + .captured + .single as DataMessage; + expect(capturedMessage.streamId, STREAM_ID); + expect(capturedMessage.bytes, bytes); + + verifyNoMoreInteractions(windowMock); + verify(streamQueueMock.bufferIndicator).called(greaterThan(0)); + verifyNoMoreInteractions(streamQueueMock); + + // TODO: Write tests for adding HeadersFrame/PushPromiseFrame. + }); + + test('connection-ignored-message-queue-in', () { + const STREAM_ID = 99; + final bytes = [1, 2, 3]; + + var windowMock = MockIncomingWindowHandler(); + when(windowMock.gotData(any)).thenReturn(null); + var queue = ConnectionMessageQueueIn(windowMock, (f) => f()); + + // Insert a [DataFrame] and let it be buffered. + var header = FrameHeader(0, 0, 0, STREAM_ID); + queue.processIgnoredDataFrame(DataFrame(header, 0, bytes)); + expect(queue.pendingMessages, 0); + verify(windowMock.dataProcessed(bytes.length)).called(1); + verifyNoMoreInteractions(windowMock); + }); + }); +} + +class MockOutgoingWindowHandler extends Mock + implements OutgoingConnectionWindowHandler, OutgoingStreamWindowHandler { + @override + BufferIndicator positiveWindow = BufferIndicator(); + @override + int peerWindowSize = Window().size; +} diff --git a/pkgs/http2/test/src/flowcontrol/mocks.dart b/pkgs/http2/test/src/flowcontrol/mocks.dart new file mode 100644 index 0000000000..c30048936c --- /dev/null +++ b/pkgs/http2/test/src/flowcontrol/mocks.dart @@ -0,0 +1,25 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/flowcontrol/connection_queues.dart'; +import 'package:http2/src/flowcontrol/stream_queues.dart'; +import 'package:http2/src/flowcontrol/window_handler.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:mockito/annotations.dart'; + +@GenerateMocks([ + FrameWriter, + IncomingWindowHandler, + OutgoingStreamWindowHandler, +], customMocks: [ + MockSpec(fallbackGenerators: { + #ensureNotTerminatedSync: ensureNotTerminatedSyncFallback, + }), + MockSpec(fallbackGenerators: { + #ensureNotTerminatedSync: ensureNotTerminatedSyncFallback, + }) +]) +T ensureNotTerminatedSyncFallback(T Function()? f) => + throw UnimplementedError( + 'Method cannot be stubbed; requires fallback values for return'); diff --git a/pkgs/http2/test/src/flowcontrol/mocks.mocks.dart b/pkgs/http2/test/src/flowcontrol/mocks.mocks.dart new file mode 100644 index 0000000000..0f09888cb6 --- /dev/null +++ b/pkgs/http2/test/src/flowcontrol/mocks.mocks.dart @@ -0,0 +1,622 @@ +// Mocks generated by Mockito 5.4.1 from annotations +// in http2/test/src/flowcontrol/mocks.dart. +// Do not manually edit this file. + +// @dart=2.19 + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:http2/src/async_utils/async_utils.dart' as _i2; +import 'package:http2/src/flowcontrol/connection_queues.dart' as _i7; +import 'package:http2/src/flowcontrol/queue_messages.dart' as _i8; +import 'package:http2/src/flowcontrol/stream_queues.dart' as _i10; +import 'package:http2/src/flowcontrol/window_handler.dart' as _i3; +import 'package:http2/src/frames/frames.dart' as _i4; +import 'package:http2/src/hpack/hpack.dart' as _i6; +import 'package:http2/transport.dart' as _i11; +import 'package:mockito/mockito.dart' as _i1; + +import 'mocks.dart' as _i9; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeBufferIndicator_0 extends _i1.SmartFake + implements _i2.BufferIndicator { + _FakeBufferIndicator_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeIncomingWindowHandler_1 extends _i1.SmartFake + implements _i3.IncomingWindowHandler { + _FakeIncomingWindowHandler_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [FrameWriter]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFrameWriter extends _i1.Mock implements _i4.FrameWriter { + MockFrameWriter() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BufferIndicator get bufferIndicator => (super.noSuchMethod( + Invocation.getter(#bufferIndicator), + returnValue: _FakeBufferIndicator_0( + this, + Invocation.getter(#bufferIndicator), + ), + ) as _i2.BufferIndicator); + @override + int get highestWrittenStreamId => (super.noSuchMethod( + Invocation.getter(#highestWrittenStreamId), + returnValue: 0, + ) as int); + @override + _i5.Future get doneFuture => (super.noSuchMethod( + Invocation.getter(#doneFuture), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + void writeDataFrame( + int? streamId, + List? data, { + bool? endStream = false, + }) => + super.noSuchMethod( + Invocation.method( + #writeDataFrame, + [ + streamId, + data, + ], + {#endStream: endStream}, + ), + returnValueForMissingStub: null, + ); + @override + void writeHeadersFrame( + int? streamId, + List<_i6.Header>? headers, { + bool? endStream = true, + }) => + super.noSuchMethod( + Invocation.method( + #writeHeadersFrame, + [ + streamId, + headers, + ], + {#endStream: endStream}, + ), + returnValueForMissingStub: null, + ); + @override + void writePriorityFrame( + int? streamId, + int? streamDependency, + int? weight, { + bool? exclusive = false, + }) => + super.noSuchMethod( + Invocation.method( + #writePriorityFrame, + [ + streamId, + streamDependency, + weight, + ], + {#exclusive: exclusive}, + ), + returnValueForMissingStub: null, + ); + @override + void writeRstStreamFrame( + int? streamId, + int? errorCode, + ) => + super.noSuchMethod( + Invocation.method( + #writeRstStreamFrame, + [ + streamId, + errorCode, + ], + ), + returnValueForMissingStub: null, + ); + @override + void writeSettingsFrame(List<_i4.Setting>? settings) => super.noSuchMethod( + Invocation.method( + #writeSettingsFrame, + [settings], + ), + returnValueForMissingStub: null, + ); + @override + void writeSettingsAckFrame() => super.noSuchMethod( + Invocation.method( + #writeSettingsAckFrame, + [], + ), + returnValueForMissingStub: null, + ); + @override + void writePushPromiseFrame( + int? streamId, + int? promisedStreamId, + List<_i6.Header>? headers, + ) => + super.noSuchMethod( + Invocation.method( + #writePushPromiseFrame, + [ + streamId, + promisedStreamId, + headers, + ], + ), + returnValueForMissingStub: null, + ); + @override + void writePingFrame( + int? opaqueData, { + bool? ack = false, + }) => + super.noSuchMethod( + Invocation.method( + #writePingFrame, + [opaqueData], + {#ack: ack}, + ), + returnValueForMissingStub: null, + ); + @override + void writeGoawayFrame( + int? lastStreamId, + int? errorCode, + List? debugData, + ) => + super.noSuchMethod( + Invocation.method( + #writeGoawayFrame, + [ + lastStreamId, + errorCode, + debugData, + ], + ), + returnValueForMissingStub: null, + ); + @override + void writeWindowUpdate( + int? sizeIncrement, { + int? streamId = 0, + }) => + super.noSuchMethod( + Invocation.method( + #writeWindowUpdate, + [sizeIncrement], + {#streamId: streamId}, + ), + returnValueForMissingStub: null, + ); + @override + _i5.Future close() => (super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); +} + +/// A class which mocks [IncomingWindowHandler]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIncomingWindowHandler extends _i1.Mock + implements _i3.IncomingWindowHandler { + MockIncomingWindowHandler() { + _i1.throwOnMissingStub(this); + } + + @override + int get localWindowSize => (super.noSuchMethod( + Invocation.getter(#localWindowSize), + returnValue: 0, + ) as int); + @override + void gotData(int? numberOfBytes) => super.noSuchMethod( + Invocation.method( + #gotData, + [numberOfBytes], + ), + returnValueForMissingStub: null, + ); + @override + void dataProcessed(int? numberOfBytes) => super.noSuchMethod( + Invocation.method( + #dataProcessed, + [numberOfBytes], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [OutgoingStreamWindowHandler]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockOutgoingStreamWindowHandler extends _i1.Mock + implements _i3.OutgoingStreamWindowHandler { + MockOutgoingStreamWindowHandler() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BufferIndicator get positiveWindow => (super.noSuchMethod( + Invocation.getter(#positiveWindow), + returnValue: _FakeBufferIndicator_0( + this, + Invocation.getter(#positiveWindow), + ), + ) as _i2.BufferIndicator); + @override + int get peerWindowSize => (super.noSuchMethod( + Invocation.getter(#peerWindowSize), + returnValue: 0, + ) as int); + @override + void processInitialWindowSizeSettingChange(int? difference) => + super.noSuchMethod( + Invocation.method( + #processInitialWindowSizeSettingChange, + [difference], + ), + returnValueForMissingStub: null, + ); + @override + void processWindowUpdate(_i4.WindowUpdateFrame? frame) => super.noSuchMethod( + Invocation.method( + #processWindowUpdate, + [frame], + ), + returnValueForMissingStub: null, + ); + @override + void decreaseWindow(int? numberOfBytes) => super.noSuchMethod( + Invocation.method( + #decreaseWindow, + [numberOfBytes], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [ConnectionMessageQueueOut]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockConnectionMessageQueueOut extends _i1.Mock + implements _i7.ConnectionMessageQueueOut { + MockConnectionMessageQueueOut() { + _i1.throwOnMissingStub(this); + } + + @override + int get pendingMessages => (super.noSuchMethod( + Invocation.getter(#pendingMessages), + returnValue: 0, + ) as int); + @override + bool get wasTerminated => (super.noSuchMethod( + Invocation.getter(#wasTerminated), + returnValue: false, + ) as bool); + @override + _i5.Future get done => (super.noSuchMethod( + Invocation.getter(#done), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + bool get isClosing => (super.noSuchMethod( + Invocation.getter(#isClosing), + returnValue: false, + ) as bool); + @override + bool get wasClosed => (super.noSuchMethod( + Invocation.getter(#wasClosed), + returnValue: false, + ) as bool); + @override + void enqueueMessage(_i8.Message? message) => super.noSuchMethod( + Invocation.method( + #enqueueMessage, + [message], + ), + returnValueForMissingStub: null, + ); + @override + void onTerminated(Object? error) => super.noSuchMethod( + Invocation.method( + #onTerminated, + [error], + ), + returnValueForMissingStub: null, + ); + @override + void onCheckForClose() => super.noSuchMethod( + Invocation.method( + #onCheckForClose, + [], + ), + returnValueForMissingStub: null, + ); + @override + void terminate([dynamic error]) => super.noSuchMethod( + Invocation.method( + #terminate, + [error], + ), + returnValueForMissingStub: null, + ); + @override + T ensureNotTerminatedSync(T Function()? f) => (super.noSuchMethod( + Invocation.method( + #ensureNotTerminatedSync, + [f], + ), + returnValue: _i9.ensureNotTerminatedSyncFallback(f), + ) as T); + @override + _i5.Future ensureNotTerminatedAsync( + _i5.Future Function()? f) => + (super.noSuchMethod( + Invocation.method( + #ensureNotTerminatedAsync, + [f], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + void startClosing() => super.noSuchMethod( + Invocation.method( + #startClosing, + [], + ), + returnValueForMissingStub: null, + ); + @override + void onClosing() => super.noSuchMethod( + Invocation.method( + #onClosing, + [], + ), + returnValueForMissingStub: null, + ); + @override + dynamic ensureNotClosingSync(dynamic Function()? f) => + super.noSuchMethod(Invocation.method( + #ensureNotClosingSync, + [f], + )); + @override + void closeWithValue([dynamic value]) => super.noSuchMethod( + Invocation.method( + #closeWithValue, + [value], + ), + returnValueForMissingStub: null, + ); + @override + void closeWithError(dynamic error) => super.noSuchMethod( + Invocation.method( + #closeWithError, + [error], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [StreamMessageQueueIn]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockStreamMessageQueueIn extends _i1.Mock + implements _i10.StreamMessageQueueIn { + MockStreamMessageQueueIn() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.IncomingWindowHandler get windowHandler => (super.noSuchMethod( + Invocation.getter(#windowHandler), + returnValue: _FakeIncomingWindowHandler_1( + this, + Invocation.getter(#windowHandler), + ), + ) as _i3.IncomingWindowHandler); + @override + _i2.BufferIndicator get bufferIndicator => (super.noSuchMethod( + Invocation.getter(#bufferIndicator), + returnValue: _FakeBufferIndicator_0( + this, + Invocation.getter(#bufferIndicator), + ), + ) as _i2.BufferIndicator); + @override + int get pendingMessages => (super.noSuchMethod( + Invocation.getter(#pendingMessages), + returnValue: 0, + ) as int); + @override + _i5.Stream<_i11.StreamMessage> get messages => (super.noSuchMethod( + Invocation.getter(#messages), + returnValue: _i5.Stream<_i11.StreamMessage>.empty(), + ) as _i5.Stream<_i11.StreamMessage>); + @override + _i5.Stream<_i11.TransportStreamPush> get serverPushes => (super.noSuchMethod( + Invocation.getter(#serverPushes), + returnValue: _i5.Stream<_i11.TransportStreamPush>.empty(), + ) as _i5.Stream<_i11.TransportStreamPush>); + @override + bool get wasTerminated => (super.noSuchMethod( + Invocation.getter(#wasTerminated), + returnValue: false, + ) as bool); + @override + _i5.Future get done => (super.noSuchMethod( + Invocation.getter(#done), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + bool get isClosing => (super.noSuchMethod( + Invocation.getter(#isClosing), + returnValue: false, + ) as bool); + @override + bool get wasClosed => (super.noSuchMethod( + Invocation.getter(#wasClosed), + returnValue: false, + ) as bool); + @override + _i5.Future get onCancel => (super.noSuchMethod( + Invocation.getter(#onCancel), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + bool get wasCancelled => (super.noSuchMethod( + Invocation.getter(#wasCancelled), + returnValue: false, + ) as bool); + @override + void enqueueMessage(_i8.Message? message) => super.noSuchMethod( + Invocation.method( + #enqueueMessage, + [message], + ), + returnValueForMissingStub: null, + ); + @override + void onTerminated(Object? error) => super.noSuchMethod( + Invocation.method( + #onTerminated, + [error], + ), + returnValueForMissingStub: null, + ); + @override + void onCloseCheck() => super.noSuchMethod( + Invocation.method( + #onCloseCheck, + [], + ), + returnValueForMissingStub: null, + ); + @override + void forceDispatchIncomingMessages() => super.noSuchMethod( + Invocation.method( + #forceDispatchIncomingMessages, + [], + ), + returnValueForMissingStub: null, + ); + @override + void terminate([dynamic error]) => super.noSuchMethod( + Invocation.method( + #terminate, + [error], + ), + returnValueForMissingStub: null, + ); + @override + T ensureNotTerminatedSync(T Function()? f) => (super.noSuchMethod( + Invocation.method( + #ensureNotTerminatedSync, + [f], + ), + returnValue: _i9.ensureNotTerminatedSyncFallback(f), + ) as T); + @override + _i5.Future ensureNotTerminatedAsync( + _i5.Future Function()? f) => + (super.noSuchMethod( + Invocation.method( + #ensureNotTerminatedAsync, + [f], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + @override + void startClosing() => super.noSuchMethod( + Invocation.method( + #startClosing, + [], + ), + returnValueForMissingStub: null, + ); + @override + void onCheckForClose() => super.noSuchMethod( + Invocation.method( + #onCheckForClose, + [], + ), + returnValueForMissingStub: null, + ); + @override + void onClosing() => super.noSuchMethod( + Invocation.method( + #onClosing, + [], + ), + returnValueForMissingStub: null, + ); + @override + dynamic ensureNotClosingSync(dynamic Function()? f) => + super.noSuchMethod(Invocation.method( + #ensureNotClosingSync, + [f], + )); + @override + void closeWithValue([dynamic value]) => super.noSuchMethod( + Invocation.method( + #closeWithValue, + [value], + ), + returnValueForMissingStub: null, + ); + @override + void closeWithError(dynamic error) => super.noSuchMethod( + Invocation.method( + #closeWithError, + [error], + ), + returnValueForMissingStub: null, + ); + @override + void cancel() => super.noSuchMethod( + Invocation.method( + #cancel, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/pkgs/http2/test/src/flowcontrol/stream_queues_test.dart b/pkgs/http2/test/src/flowcontrol/stream_queues_test.dart new file mode 100644 index 0000000000..0b5ae7a7a1 --- /dev/null +++ b/pkgs/http2/test/src/flowcontrol/stream_queues_test.dart @@ -0,0 +1,150 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/async_utils/async_utils.dart'; +import 'package:http2/src/flowcontrol/queue_messages.dart'; +import 'package:http2/src/flowcontrol/stream_queues.dart'; +import 'package:http2/transport.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'mocks.mocks.dart'; + +void main() { + group('flowcontrol', () { + const STREAM_ID = 99; + const BYTES = [1, 2, 3]; + + group('stream-message-queue-out', () { + test('window-big-enough', () { + var connectionQueueMock = MockConnectionMessageQueueOut(); + when(connectionQueueMock.enqueueMessage(any)).thenReturn(null); + var windowMock = MockOutgoingStreamWindowHandler(); + when(windowMock.positiveWindow).thenReturn(BufferIndicator()); + when(windowMock.decreaseWindow(any)).thenReturn(null); + + windowMock.positiveWindow.markUnBuffered(); + var queue = + StreamMessageQueueOut(STREAM_ID, windowMock, connectionQueueMock); + + expect(queue.bufferIndicator.wouldBuffer, isFalse); + expect(queue.pendingMessages, 0); + when(windowMock.peerWindowSize).thenReturn(BYTES.length); + + queue.enqueueMessage(DataMessage(STREAM_ID, BYTES, true)); + verify(windowMock.decreaseWindow(BYTES.length)).called(1); + final capturedMessage = + verify(connectionQueueMock.enqueueMessage(captureAny)) + .captured + .single; + expect(capturedMessage, const TypeMatcher()); + var capturedDataMessage = capturedMessage as DataMessage; + expect(capturedDataMessage.bytes, BYTES); + expect(capturedDataMessage.endStream, isTrue); + }); + + test('window-smaller-than-necessary', () { + var connectionQueueMock = MockConnectionMessageQueueOut(); + when(connectionQueueMock.enqueueMessage(any)).thenReturn(null); + var windowMock = MockOutgoingStreamWindowHandler(); + when(windowMock.positiveWindow).thenReturn(BufferIndicator()); + when(windowMock.decreaseWindow(any)).thenReturn(null); + windowMock.positiveWindow.markUnBuffered(); + var queue = + StreamMessageQueueOut(STREAM_ID, windowMock, connectionQueueMock); + + expect(queue.bufferIndicator.wouldBuffer, isFalse); + expect(queue.pendingMessages, 0); + + // We set the window size fixed to 1, which means all the data messages + // will get fragmented to 1 byte. + when(windowMock.peerWindowSize).thenReturn(1); + queue.enqueueMessage(DataMessage(STREAM_ID, BYTES, true)); + + expect(queue.pendingMessages, 0); + verify(windowMock.decreaseWindow(1)).called(BYTES.length); + final messages = + verify(connectionQueueMock.enqueueMessage(captureAny)).captured; + expect(messages, hasLength(BYTES.length)); + for (var counter = 0; counter < messages.length; counter++) { + expect(messages[counter], const TypeMatcher()); + var dataMessage = messages[counter] as DataMessage; + expect(dataMessage.bytes, BYTES.sublist(counter, counter + 1)); + expect(dataMessage.endStream, counter == BYTES.length - 1); + } + verify(windowMock.positiveWindow).called(greaterThan(0)); + verify(windowMock.peerWindowSize).called(greaterThan(0)); + verifyNoMoreInteractions(windowMock); + }); + + test('window-empty', () { + var connectionQueueMock = MockConnectionMessageQueueOut(); + var windowMock = MockOutgoingStreamWindowHandler(); + when(windowMock.positiveWindow).thenReturn(BufferIndicator()); + windowMock.positiveWindow.markUnBuffered(); + var queue = + StreamMessageQueueOut(STREAM_ID, windowMock, connectionQueueMock); + + expect(queue.bufferIndicator.wouldBuffer, isFalse); + expect(queue.pendingMessages, 0); + + when(windowMock.peerWindowSize).thenReturn(0); + queue.enqueueMessage(DataMessage(STREAM_ID, BYTES, true)); + expect(queue.bufferIndicator.wouldBuffer, isTrue); + expect(queue.pendingMessages, 1); + verify(windowMock.positiveWindow).called(greaterThan(0)); + verify(windowMock.peerWindowSize).called(greaterThan(0)); + verifyNoMoreInteractions(windowMock); + verifyZeroInteractions(connectionQueueMock); + }); + }); + + group('stream-message-queue-in', () { + test('data-end-of-stream', () { + var windowMock = MockIncomingWindowHandler(); + when(windowMock.gotData(any)).thenReturn(null); + when(windowMock.dataProcessed(any)).thenReturn(null); + var queue = StreamMessageQueueIn(windowMock); + + expect(queue.pendingMessages, 0); + queue.messages.listen(expectAsync1((StreamMessage message) { + expect(message, isA()); + + var dataMessage = message as DataStreamMessage; + expect(dataMessage.bytes, BYTES); + }), onDone: expectAsync0(() {})); + queue.enqueueMessage(DataMessage(STREAM_ID, BYTES, true)); + expect(queue.bufferIndicator.wouldBuffer, isFalse); + verifyInOrder([ + windowMock.gotData(BYTES.length), + windowMock.dataProcessed(BYTES.length) + ]); + verifyNoMoreInteractions(windowMock); + }); + }); + + test('data-end-of-stream--paused', () { + const STREAM_ID = 99; + final bytes = [1, 2, 3]; + + var windowMock = MockIncomingWindowHandler(); + when(windowMock.gotData(any)).thenReturn(null); + var queue = StreamMessageQueueIn(windowMock); + + var sub = queue.messages.listen(expectAsync1((_) {}, count: 0), + onDone: expectAsync0(() {}, count: 0)); + sub.pause(); + + expect(queue.pendingMessages, 0); + queue.enqueueMessage(DataMessage(STREAM_ID, bytes, true)); + expect(queue.pendingMessages, 1); + expect(queue.bufferIndicator.wouldBuffer, isTrue); + // We assert that we got the data, but it wasn't processed. + verify(windowMock.gotData(bytes.length)).called(1); + // verifyNever(windowMock.dataProcessed(any)); + }); + + // TODO: Add tests for Headers/HeadersPush messages. + }); +} diff --git a/pkgs/http2/test/src/flowcontrol/window_handler_test.dart b/pkgs/http2/test/src/flowcontrol/window_handler_test.dart new file mode 100644 index 0000000000..017dadd4ef --- /dev/null +++ b/pkgs/http2/test/src/flowcontrol/window_handler_test.dart @@ -0,0 +1,131 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/flowcontrol/window.dart'; +import 'package:http2/src/flowcontrol/window_handler.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../error_matchers.dart'; + +void main() { + group('flowcontrol', () { + void testAbstractOutgoingWindowHandler( + AbstractOutgoingWindowHandler handler, Window window, int initialSize) { + var sub = handler.positiveWindow.bufferEmptyEvents + .listen(expectAsync1((_) {}, count: 0)); + + expect(handler.peerWindowSize, initialSize); + expect(window.size, initialSize); + + // If we're sending data to the remote end, we need to subtract + // the number of bytes from the outgoing connection (and stream) windows. + handler.decreaseWindow(100); + expect(handler.peerWindowSize, initialSize - 100); + expect(window.size, initialSize - 100); + + // If we received a window update frame, the window should be increased + // again. + var frameHeader = FrameHeader(4, FrameType.WINDOW_UPDATE, 0, 0); + handler.processWindowUpdate(WindowUpdateFrame(frameHeader, 100)); + expect(handler.peerWindowSize, initialSize); + expect(window.size, initialSize); + + sub.cancel(); + + // If we decrease the outgoing window size to 0 or below, and + // increase it again, we expect to get an update event. + expect(handler.positiveWindow.wouldBuffer, isFalse); + handler.decreaseWindow(window.size); + expect(handler.positiveWindow.wouldBuffer, isTrue); + sub = handler.positiveWindow.bufferEmptyEvents.listen(expectAsync1((_) { + expect(handler.peerWindowSize, 1); + expect(window.size, 1); + })); + + // Now we trigger the 1 byte window increase + handler.processWindowUpdate(WindowUpdateFrame(frameHeader, 1)); + sub.cancel(); + + // If the remote end sends us [WindowUpdateFrame]s which increase it above + // the maximum size, we throw a [FlowControlException]. + var frame = WindowUpdateFrame(frameHeader, Window.MAX_WINDOW_SIZE); + expect(() => handler.processWindowUpdate(frame), + throwsA(isFlowControlException)); + } + + test('outgoing-connection-window-handler', () { + var window = Window(); + var initialSize = window.size; + var handler = OutgoingConnectionWindowHandler(window); + + testAbstractOutgoingWindowHandler(handler, window, initialSize); + }); + + test('outgoing-stream-window-handler', () { + var window = Window(); + var initialSize = window.size; + var handler = OutgoingStreamWindowHandler(window); + + testAbstractOutgoingWindowHandler(handler, window, initialSize); + + // Test stream specific functionality: If the connection window + // gets increased/decreased via a [SettingsFrame], all stream + // windows need to get updated as well. + + window = Window(); + initialSize = window.size; + handler = OutgoingStreamWindowHandler(window); + + expect(handler.positiveWindow.wouldBuffer, isFalse); + final bufferEmpty = handler.positiveWindow.bufferEmptyEvents + .listen(expectAsync1((_) {}, count: 0)); + handler.processInitialWindowSizeSettingChange(-window.size); + expect(handler.positiveWindow.wouldBuffer, isTrue); + expect(handler.peerWindowSize, 0); + expect(window.size, 0); + bufferEmpty.onData(expectAsync1((_) {}, count: 1)); + handler.processInitialWindowSizeSettingChange(1); + expect(handler.positiveWindow.wouldBuffer, isFalse); + expect(handler.peerWindowSize, 1); + expect(window.size, 1); + + expect( + () => handler.processInitialWindowSizeSettingChange( + Window.MAX_WINDOW_SIZE + 1), + throwsA(isFlowControlException)); + }); + + test('incoming-window-handler', () { + const STREAM_ID = 99; + + var fw = FrameWriterMock(); + var window = Window(); + var initialSize = window.size; + var handler = IncomingWindowHandler.stream(fw, window, STREAM_ID); + + expect(handler.localWindowSize, initialSize); + expect(window.size, initialSize); + + // If the remote end sends us now 100 bytes, it reduces the local + // incoming window by 100 bytes. Once we handled these bytes, it, + // will send a [WindowUpdateFrame] to the remote peer to ACK it. + handler.gotData(100); + expect(handler.localWindowSize, initialSize - 100); + expect(window.size, initialSize - 100); + + // The data might sit in a queue. Once the user drains enough data of + // the queue, we will start ACKing the data and the window becomes + // positive again. + handler.dataProcessed(100); + expect(handler.localWindowSize, initialSize); + expect(window.size, initialSize); + verify(fw.writeWindowUpdate(100, streamId: STREAM_ID)).called(1); + verifyNoMoreInteractions(fw); + }); + }); +} + +class FrameWriterMock extends Mock implements FrameWriter {} diff --git a/pkgs/http2/test/src/frames/frame_defragmenter_test.dart b/pkgs/http2/test/src/frames/frame_defragmenter_test.dart new file mode 100644 index 0000000000..04abec1195 --- /dev/null +++ b/pkgs/http2/test/src/frames/frame_defragmenter_test.dart @@ -0,0 +1,132 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/frames/frame_defragmenter.dart'; +import 'package:http2/src/frames/frames.dart'; +import 'package:test/test.dart'; + +import '../error_matchers.dart'; + +void main() { + group('frames', () { + group('frame-defragmenter', () { + UnknownFrame unknownFrame() { + return UnknownFrame(FrameHeader(0, 0, 0, 1), []); + } + + HeadersFrame headersFrame(List data, + {bool fragmented = false, int streamId = 1}) { + var flags = fragmented ? 0 : HeadersFrame.FLAG_END_HEADERS; + var header = + FrameHeader(data.length, FrameType.HEADERS, flags, streamId); + return HeadersFrame(header, 0, false, null, null, data); + } + + PushPromiseFrame pushPromiseFrame(List data, + {bool fragmented = false, int streamId = 1}) { + var flags = fragmented ? 0 : HeadersFrame.FLAG_END_HEADERS; + var header = + FrameHeader(data.length, FrameType.PUSH_PROMISE, flags, streamId); + return PushPromiseFrame(header, 0, 44, data); + } + + ContinuationFrame continuationFrame(List data, + {bool fragmented = false, int streamId = 1}) { + var flags = fragmented ? 0 : ContinuationFrame.FLAG_END_HEADERS; + var header = + FrameHeader(data.length, FrameType.CONTINUATION, flags, streamId); + return ContinuationFrame(header, data); + } + + test('unknown-frame', () { + var defrag = FrameDefragmenter(); + expect(defrag.tryDefragmentFrame(unknownFrame()) is UnknownFrame, true); + }); + + test('fragmented-headers-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = headersFrame([1, 2, 3], fragmented: true); + var f2 = continuationFrame([4, 5, 6], fragmented: true); + var f3 = continuationFrame([7, 8, 9], fragmented: false); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect(defrag.tryDefragmentFrame(f2), isNull); + var h = defrag.tryDefragmentFrame(f3) as HeadersFrame; + expect(h.hasEndHeadersFlag, isTrue); + expect(h.hasEndStreamFlag, isFalse); + expect(h.hasPaddedFlag, isFalse); + expect(h.padLength, 0); + expect(h.headerBlockFragment, [1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + test('fragmented-push-promise-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = pushPromiseFrame([1, 2, 3], fragmented: true); + var f2 = continuationFrame([4, 5, 6], fragmented: true); + var f3 = continuationFrame([7, 8, 9], fragmented: false); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect(defrag.tryDefragmentFrame(f2), isNull); + var h = defrag.tryDefragmentFrame(f3) as PushPromiseFrame; + expect(h.hasEndHeadersFlag, isTrue); + expect(h.hasPaddedFlag, isFalse); + expect(h.padLength, 0); + expect(h.headerBlockFragment, [1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + test('fragmented-headers-frame--wrong-id', () { + var defrag = FrameDefragmenter(); + + var f1 = headersFrame([1, 2, 3], fragmented: true, streamId: 1); + var f2 = continuationFrame([4, 5, 6], fragmented: true, streamId: 2); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect( + () => defrag.tryDefragmentFrame(f2), throwsA(isProtocolException)); + }); + + test('fragmented-push-promise-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = pushPromiseFrame([1, 2, 3], fragmented: true, streamId: 1); + var f2 = continuationFrame([4, 5, 6], fragmented: true, streamId: 2); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect( + () => defrag.tryDefragmentFrame(f2), throwsA(isProtocolException)); + }); + + test('fragmented-headers-frame--no-continuation-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = headersFrame([1, 2, 3], fragmented: true); + var f2 = unknownFrame(); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect( + () => defrag.tryDefragmentFrame(f2), throwsA(isProtocolException)); + }); + + test('fragmented-push-promise-no-continuation-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = pushPromiseFrame([1, 2, 3], fragmented: true); + var f2 = unknownFrame(); + + expect(defrag.tryDefragmentFrame(f1), isNull); + expect( + () => defrag.tryDefragmentFrame(f2), throwsA(isProtocolException)); + }); + + test('push-without-headres-or-push-promise-frame', () { + var defrag = FrameDefragmenter(); + + var f1 = continuationFrame([4, 5, 6], fragmented: true, streamId: 1); + expect(defrag.tryDefragmentFrame(f1), equals(f1)); + }); + }); + }); +} diff --git a/pkgs/http2/test/src/frames/frame_reader_test.dart b/pkgs/http2/test/src/frames/frame_reader_test.dart new file mode 100644 index 0000000000..9cdf068c40 --- /dev/null +++ b/pkgs/http2/test/src/frames/frame_reader_test.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:test/test.dart'; + +void main() { + group('frames', () { + group('frame-reader', () { + final maxFrameSize = ActiveSettings().maxFrameSize; + + Stream dataFrame(List body) { + var settings = ActiveSettings(); + var controller = StreamController>(); + var reader = FrameReader(controller.stream, settings); + + // This is a DataFrame: + // - length: n + // - type: [0] + // - flags: [0] + // - stream id: [0, 0, 0, 1] + controller + ..add([0, (body.length >> 8) & 0xff, body.length & 0xff]) + ..add([0]) + ..add([0]) + ..add([0, 0, 0, 1]) + ..add(body) + ..close(); + return reader.startDecoding(); + } + + test('data-frame--max-frame-size', () { + var body = List.filled(maxFrameSize, 0x42); + dataFrame(body).listen(expectAsync1((Frame frame) { + expect(frame, isA()); + expect(frame.header, hasLength(body.length)); + expect(frame.header.flags, 0); + var dataFrame = frame as DataFrame; + expect(dataFrame.hasEndStreamFlag, isFalse); + expect(dataFrame.hasPaddedFlag, isFalse); + expect(dataFrame.bytes, body); + }), + onError: + expectAsync2((Object error, StackTrace stack) {}, count: 0)); + }); + + test('data-frame--max-frame-size-plus-1', () { + var body = List.filled(maxFrameSize + 1, 0x42); + dataFrame(body).listen(expectAsync1((_) {}, count: 0), + onError: expectAsync2((Object error, StackTrace stack) { + expect('$error', contains('Incoming frame is too big')); + })); + }); + + test('incomplete-header', () { + var settings = ActiveSettings(); + + var controller = StreamController>(); + var reader = FrameReader(controller.stream, settings); + + controller + ..add([1]) + ..close(); + + reader.startDecoding().listen(expectAsync1((_) {}, count: 0), + onError: expectAsync2((Object error, StackTrace stack) { + expect('$error', contains('incomplete frame')); + })); + }); + + test('incomplete-frame', () { + var settings = ActiveSettings(); + + var controller = StreamController>(); + var reader = FrameReader(controller.stream, settings); + + // This is a DataFrame: + // - length: [0, 0, 255] + // - type: [0] + // - flags: [0] + // - stream id: [0, 0, 0, 1] + controller + ..add([0, 0, 255, 0, 0, 0, 0, 0, 1]) + ..close(); + + reader.startDecoding().listen(expectAsync1((_) {}, count: 0), + onError: expectAsync2((Object error, StackTrace stack) { + expect('$error', contains('incomplete frame')); + })); + }); + + test('connection-error', () { + var settings = ActiveSettings(); + + var controller = StreamController>(); + var reader = FrameReader(controller.stream, settings); + + controller + ..addError('hello world') + ..close(); + + reader.startDecoding().listen(expectAsync1((_) {}, count: 0), + onError: expectAsync2((Object error, StackTrace stack) { + expect('$error', contains('hello world')); + })); + }); + }); + }); +} diff --git a/pkgs/http2/test/src/frames/frame_writer_reader_test.dart b/pkgs/http2/test/src/frames/frame_writer_reader_test.dart new file mode 100644 index 0000000000..28bc59cddd --- /dev/null +++ b/pkgs/http2/test/src/frames/frame_writer_reader_test.dart @@ -0,0 +1,232 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/hpack/hpack.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:test/test.dart'; + +import '../hpack/hpack_test.dart' show isHeader; + +void main() { + group('frames', () { + group('writer-reader', () { + writerReaderTest('data-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeDataFrame(99, [1, 2, 3], endStream: true); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is DataFrame, isTrue); + + var dataFrame = frames[0] as DataFrame; + expect(dataFrame.header.streamId, 99); + expect(dataFrame.hasPaddedFlag, isFalse); + expect(dataFrame.padLength, 0); + expect(dataFrame.hasEndStreamFlag, isTrue); + expect(dataFrame.bytes, [1, 2, 3]); + }); + + writerReaderTest('headers-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeHeadersFrame(99, [Header.ascii('a', 'b')], endStream: true); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is HeadersFrame, isTrue); + + var headersFrame = frames[0] as HeadersFrame; + expect(headersFrame.header.streamId, 99); + expect(headersFrame.hasPaddedFlag, isFalse); + expect(headersFrame.padLength, 0); + expect(headersFrame.hasEndStreamFlag, isTrue); + expect(headersFrame.hasEndHeadersFlag, isTrue); + expect(headersFrame.exclusiveDependency, false); + expect(headersFrame.hasPriorityFlag, false); + expect(headersFrame.streamDependency, isNull); + expect(headersFrame.weight, isNull); + + var headers = decoder.decode(headersFrame.headerBlockFragment); + expect(headers, hasLength(1)); + expect(headers[0], isHeader('a', 'b')); + }); + + writerReaderTest('priority-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writePriorityFrame(99, 44, 33, exclusive: true); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is PriorityFrame, isTrue); + + var priorityFrame = frames[0] as PriorityFrame; + expect(priorityFrame.header.streamId, 99); + expect(priorityFrame.exclusiveDependency, isTrue); + expect(priorityFrame.streamDependency, 44); + expect(priorityFrame.weight, 33); + }); + + writerReaderTest('rst-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeRstStreamFrame(99, 42); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is RstStreamFrame, isTrue); + + var rstFrame = frames[0] as RstStreamFrame; + expect(rstFrame.header.streamId, 99); + expect(rstFrame.errorCode, 42); + }); + + writerReaderTest('settings-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeSettingsFrame([Setting(Setting.SETTINGS_ENABLE_PUSH, 1)]); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is SettingsFrame, isTrue); + + var settingsFrame = frames[0] as SettingsFrame; + expect(settingsFrame.hasAckFlag, false); + expect(settingsFrame.header.streamId, 0); + expect(settingsFrame.settings, hasLength(1)); + expect( + settingsFrame.settings[0].identifier, Setting.SETTINGS_ENABLE_PUSH); + expect(settingsFrame.settings[0].value, 1); + }); + + writerReaderTest('settings-frame-ack', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeSettingsAckFrame(); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is SettingsFrame, isTrue); + + var settingsFrame = frames[0] as SettingsFrame; + expect(settingsFrame.hasAckFlag, true); + expect(settingsFrame.header.streamId, 0); + expect(settingsFrame.settings, hasLength(0)); + }); + + writerReaderTest('push-promise-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writePushPromiseFrame(99, 44, [Header.ascii('a', 'b')]); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is PushPromiseFrame, isTrue); + + var pushPromiseFrame = frames[0] as PushPromiseFrame; + expect(pushPromiseFrame.header.streamId, 99); + expect(pushPromiseFrame.hasEndHeadersFlag, isTrue); + expect(pushPromiseFrame.hasPaddedFlag, isFalse); + expect(pushPromiseFrame.padLength, 0); + expect(pushPromiseFrame.promisedStreamId, 44); + + var headers = decoder.decode(pushPromiseFrame.headerBlockFragment); + expect(headers, hasLength(1)); + expect(headers[0], isHeader('a', 'b')); + }); + + writerReaderTest('ping-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writePingFrame(44, ack: true); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is PingFrame, isTrue); + + var pingFrame = frames[0] as PingFrame; + expect(pingFrame.header.streamId, 0); + expect(pingFrame.opaqueData, 44); + expect(pingFrame.hasAckFlag, isTrue); + }); + + writerReaderTest('goaway-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeGoawayFrame(44, 33, [1, 2, 3]); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is GoawayFrame, isTrue); + + var goawayFrame = frames[0] as GoawayFrame; + expect(goawayFrame.header.streamId, 0); + expect(goawayFrame.lastStreamId, 44); + expect(goawayFrame.errorCode, 33); + expect(goawayFrame.debugData, [1, 2, 3]); + }); + + writerReaderTest('window-update-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + writer.writeWindowUpdate(55, streamId: 99); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(1)); + expect(frames[0] is WindowUpdateFrame, isTrue); + + var windowUpdateFrame = frames[0] as WindowUpdateFrame; + expect(windowUpdateFrame.header.streamId, 99); + expect(windowUpdateFrame.windowSizeIncrement, 55); + }); + + writerReaderTest('frag-headers-frame', + (FrameWriter writer, FrameReader reader, HPackDecoder decoder) async { + var headerName = [1]; + var headerValue = List.filled(1 << 14, 0x42); + var header = Header(headerName, headerValue); + + writer.writeHeadersFrame(99, [header], endStream: true); + + var frames = await finishWriting(writer, reader); + expect(frames, hasLength(2)); + expect(frames[0] is HeadersFrame, isTrue); + expect(frames[1] is ContinuationFrame, isTrue); + + var headersFrame = frames[0] as HeadersFrame; + expect(headersFrame.header.streamId, 99); + expect(headersFrame.hasPaddedFlag, isFalse); + expect(headersFrame.padLength, 0); + expect(headersFrame.hasEndHeadersFlag, isFalse); + expect(headersFrame.hasEndStreamFlag, isTrue); + expect(headersFrame.hasPriorityFlag, isFalse); + + var contFrame = frames[1] as ContinuationFrame; + expect(contFrame.header.streamId, 99); + expect(contFrame.hasEndHeadersFlag, isTrue); + + var headerBlock = [ + ...headersFrame.headerBlockFragment, + ...contFrame.headerBlockFragment + ]; + + var headers = decoder.decode(headerBlock); + expect(headers, hasLength(1)); + expect(headers[0].name, headerName); + expect(headers[0].value, headerValue); + }); + }); + }); +} + +void writerReaderTest(String name, + Future Function(FrameWriter, FrameReader, HPackDecoder) func) { + test(name, () { + var settings = ActiveSettings(); + var context = HPackContext(); + var controller = StreamController>(); + var writer = FrameWriter(context.encoder, controller, settings); + var reader = FrameReader(controller.stream, settings); + return func(writer, reader, context.decoder); + }); +} + +Future> finishWriting(FrameWriter writer, FrameReader reader) { + return Future.wait([writer.close(), reader.startDecoding().toList()]) + .then((results) => results.last as List); +} diff --git a/pkgs/http2/test/src/frames/frame_writer_test.dart b/pkgs/http2/test/src/frames/frame_writer_test.dart new file mode 100644 index 0000000000..24b822fef0 --- /dev/null +++ b/pkgs/http2/test/src/frames/frame_writer_test.dart @@ -0,0 +1,30 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/hpack/hpack.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:test/test.dart'; + +void main() { + group('frames', () { + group('frame-writer', () { + test('connection-error', () { + var settings = ActiveSettings(); + var context = HPackContext(); + var controller = StreamController>(); + var writer = FrameWriter(context.encoder, controller, settings); + + writer.doneFuture.then(expectAsync1((_) { + // We expect that the writer is done at this point. + })); + + // We cancel here the reading part (simulates a dying socket). + controller.stream.listen((_) {}).cancel(); + }); + }); + }); +} diff --git a/pkgs/http2/test/src/hpack/hpack_test.dart b/pkgs/http2/test/src/hpack/hpack_test.dart new file mode 100644 index 0000000000..aadf6cb17e --- /dev/null +++ b/pkgs/http2/test/src/hpack/hpack_test.dart @@ -0,0 +1,830 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/hpack/hpack.dart'; +import 'package:test/test.dart'; + +void main() { + group('hpack', () { + group('hpack-spec-decoder', () { + test('C.3 request without huffman encoding', () { + var context = HPackContext(); + List
headers; + + // First request + headers = context.decoder.decode([ + 0x82, + 0x86, + 0x84, + 0x41, + 0x0f, + 0x77, + 0x77, + 0x77, + 0x2e, + 0x65, + 0x78, + 0x61, + 0x6d, + 0x70, + 0x6c, + 0x65, + 0x2e, + 0x63, + 0x6f, + 0x6d + ]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'http')); + expect(headers[2], isHeader(':path', '/')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + + // Second request + headers = context.decoder.decode([ + 0x82, + 0x86, + 0x84, + 0xbe, + 0x58, + 0x08, + 0x6e, + 0x6f, + 0x2d, + 0x63, + 0x61, + 0x63, + 0x68, + 0x65 + ]); + expect(headers, hasLength(5)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'http')); + expect(headers[2], isHeader(':path', '/')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + expect(headers[4], isHeader('cache-control', 'no-cache')); + + // Third request + headers = context.decoder.decode([ + 0x82, + 0x87, + 0x85, + 0xbf, + 0x40, + 0x0a, + 0x63, + 0x75, + 0x73, + 0x74, + 0x6f, + 0x6d, + 0x2d, + 0x6b, + 0x65, + 0x79, + 0x0c, + 0x63, + 0x75, + 0x73, + 0x74, + 0x6f, + 0x6d, + 0x2d, + 0x76, + 0x61, + 0x6c, + 0x75, + 0x65 + ]); + expect(headers, hasLength(5)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'https')); + expect(headers[2], isHeader(':path', '/index.html')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + expect(headers[4], isHeader('custom-key', 'custom-value')); + }); + + test('C.4 request with huffman encoding', () { + var context = HPackContext(); + List
headers; + + // First request + headers = context.decoder.decode([ + 0x82, + 0x86, + 0x84, + 0x41, + 0x8c, + 0xf1, + 0xe3, + 0xc2, + 0xe5, + 0xf2, + 0x3a, + 0x6b, + 0xa0, + 0xab, + 0x90, + 0xf4, + 0xff + ]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'http')); + expect(headers[2], isHeader(':path', '/')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + + // Second request + headers = context.decoder.decode([ + 0x82, + 0x86, + 0x84, + 0xbe, + 0x58, + 0x86, + 0xa8, + 0xeb, + 0x10, + 0x64, + 0x9c, + 0xbf + ]); + expect(headers, hasLength(5)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'http')); + expect(headers[2], isHeader(':path', '/')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + expect(headers[4], isHeader('cache-control', 'no-cache')); + + // Third request + headers = context.decoder.decode([ + 0x82, + 0x87, + 0x85, + 0xbf, + 0x40, + 0x88, + 0x25, + 0xa8, + 0x49, + 0xe9, + 0x5b, + 0xa9, + 0x7d, + 0x7f, + 0x89, + 0x25, + 0xa8, + 0x49, + 0xe9, + 0x5b, + 0xb8, + 0xe8, + 0xb4, + 0xbf + ]); + expect(headers, hasLength(5)); + expect(headers[0], isHeader(':method', 'GET')); + expect(headers[1], isHeader(':scheme', 'https')); + expect(headers[2], isHeader(':path', '/index.html')); + expect(headers[3], isHeader(':authority', 'www.example.com')); + expect(headers[4], isHeader('custom-key', 'custom-value')); + }); + + test('C.5 response without huffman encoding', () { + var context = HPackContext(); + List
headers; + + // First response + headers = context.decoder.decode([ + 0x48, + 0x03, + 0x33, + 0x30, + 0x32, + 0x58, + 0x07, + 0x70, + 0x72, + 0x69, + 0x76, + 0x61, + 0x74, + 0x65, + 0x61, + 0x1d, + 0x4d, + 0x6f, + 0x6e, + 0x2c, + 0x20, + 0x32, + 0x31, + 0x20, + 0x4f, + 0x63, + 0x74, + 0x20, + 0x32, + 0x30, + 0x31, + 0x33, + 0x20, + 0x32, + 0x30, + 0x3a, + 0x31, + 0x33, + 0x3a, + 0x32, + 0x31, + 0x20, + 0x47, + 0x4d, + 0x54, + 0x6e, + 0x17, + 0x68, + 0x74, + 0x74, + 0x70, + 0x73, + 0x3a, + 0x2f, + 0x2f, + 0x77, + 0x77, + 0x77, + 0x2e, + 0x65, + 0x78, + 0x61, + 0x6d, + 0x70, + 0x6c, + 0x65, + 0x2e, + 0x63, + 0x6f, + 0x6d + ]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':status', '302')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:21 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + + // Second response + headers = context.decoder + .decode([0x48, 0x03, 0x33, 0x30, 0x37, 0xc1, 0xc0, 0xbf]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':status', '307')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:21 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + + // Third response + headers = context.decoder.decode([ + 0x88, + 0xc1, + 0x61, + 0x1d, + 0x4d, + 0x6f, + 0x6e, + 0x2c, + 0x20, + 0x32, + 0x31, + 0x20, + 0x4f, + 0x63, + 0x74, + 0x20, + 0x32, + 0x30, + 0x31, + 0x33, + 0x20, + 0x32, + 0x30, + 0x3a, + 0x31, + 0x33, + 0x3a, + 0x32, + 0x32, + 0x20, + 0x47, + 0x4d, + 0x54, + 0xc0, + 0x5a, + 0x04, + 0x67, + 0x7a, + 0x69, + 0x70, + 0x77, + 0x38, + 0x66, + 0x6f, + 0x6f, + 0x3d, + 0x41, + 0x53, + 0x44, + 0x4a, + 0x4b, + 0x48, + 0x51, + 0x4b, + 0x42, + 0x5a, + 0x58, + 0x4f, + 0x51, + 0x57, + 0x45, + 0x4f, + 0x50, + 0x49, + 0x55, + 0x41, + 0x58, + 0x51, + 0x57, + 0x45, + 0x4f, + 0x49, + 0x55, + 0x3b, + 0x20, + 0x6d, + 0x61, + 0x78, + 0x2d, + 0x61, + 0x67, + 0x65, + 0x3d, + 0x33, + 0x36, + 0x30, + 0x30, + 0x3b, + 0x20, + 0x76, + 0x65, + 0x72, + 0x73, + 0x69, + 0x6f, + 0x6e, + 0x3d, + 0x31 + ]); + expect(headers, hasLength(6)); + expect(headers[0], isHeader(':status', '200')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:22 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + expect(headers[4], isHeader('content-encoding', 'gzip')); + expect( + headers[5], + isHeader('set-cookie', + 'foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1')); + }); + + test('C.6 response with huffman encoding', () { + var context = HPackContext(); + List
headers; + + // First response + headers = context.decoder.decode([ + 0x48, + 0x82, + 0x64, + 0x02, + 0x58, + 0x85, + 0xae, + 0xc3, + 0x77, + 0x1a, + 0x4b, + 0x61, + 0x96, + 0xd0, + 0x7a, + 0xbe, + 0x94, + 0x10, + 0x54, + 0xd4, + 0x44, + 0xa8, + 0x20, + 0x05, + 0x95, + 0x04, + 0x0b, + 0x81, + 0x66, + 0xe0, + 0x82, + 0xa6, + 0x2d, + 0x1b, + 0xff, + 0x6e, + 0x91, + 0x9d, + 0x29, + 0xad, + 0x17, + 0x18, + 0x63, + 0xc7, + 0x8f, + 0x0b, + 0x97, + 0xc8, + 0xe9, + 0xae, + 0x82, + 0xae, + 0x43, + 0xd3 + ]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':status', '302')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:21 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + + // Second response + headers = context.decoder + .decode([0x48, 0x83, 0x64, 0x0e, 0xff, 0xc1, 0xc0, 0xbf]); + expect(headers, hasLength(4)); + expect(headers[0], isHeader(':status', '307')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:21 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + + // Third response + headers = context.decoder.decode([ + 0x88, + 0xc1, + 0x61, + 0x96, + 0xd0, + 0x7a, + 0xbe, + 0x94, + 0x10, + 0x54, + 0xd4, + 0x44, + 0xa8, + 0x20, + 0x05, + 0x95, + 0x04, + 0x0b, + 0x81, + 0x66, + 0xe0, + 0x84, + 0xa6, + 0x2d, + 0x1b, + 0xff, + 0xc0, + 0x5a, + 0x83, + 0x9b, + 0xd9, + 0xab, + 0x77, + 0xad, + 0x94, + 0xe7, + 0x82, + 0x1d, + 0xd7, + 0xf2, + 0xe6, + 0xc7, + 0xb3, + 0x35, + 0xdf, + 0xdf, + 0xcd, + 0x5b, + 0x39, + 0x60, + 0xd5, + 0xaf, + 0x27, + 0x08, + 0x7f, + 0x36, + 0x72, + 0xc1, + 0xab, + 0x27, + 0x0f, + 0xb5, + 0x29, + 0x1f, + 0x95, + 0x87, + 0x31, + 0x60, + 0x65, + 0xc0, + 0x03, + 0xed, + 0x4e, + 0xe5, + 0xb1, + 0x06, + 0x3d, + 0x50, + 0x07 + ]); + expect(headers, hasLength(6)); + expect(headers[0], isHeader(':status', '200')); + expect(headers[1], isHeader('cache-control', 'private')); + expect(headers[2], isHeader('date', 'Mon, 21 Oct 2013 20:13:22 GMT')); + expect(headers[3], isHeader('location', 'https://www.example.com')); + expect(headers[4], isHeader('content-encoding', 'gzip')); + expect( + headers[5], + isHeader('set-cookie', + 'foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1')); + }); + }); + + group('negative-decoder-tests', () { + test('invalid-integer-encoding', () { + var context = HPackContext(); + expect(() => context.decoder.decode([1 << 6, 0xff]), + throwsA(isHPackDecodingException)); + }); + + test('index-out-of-table-size', () { + var context = HPackContext(); + expect(() => context.decoder.decode([0x7f]), + throwsA(isHPackDecodingException)); + }); + + test('invalid-update-dynamic-table-size', () { + var context = HPackContext(); + expect(() => context.decoder.decode([0x3f]), + throwsA(isHPackDecodingException)); + }); + + test('update-dynamic-table-size-too-high', () { + var context = HPackContext(); + // Tries to set dynamic table to 4097 (max is 4096 by default) + var bytes = TestHelper.newInteger(0x20, 5, 4097); + expect(() => context.decoder.decode(bytes), + throwsA(isHPackDecodingException)); + }); + }); + + group('custom decoder tests', () { + const char0 = 0x30; + const char1 = 0x31; + const char2 = 0x31; + const char3 = 0x31; + const charA = 0x61; + const charB = 0x62; + const charC = 0x63; + const charD = 0x64; + + test('update-dynamic-table-size-too-high', () { + var context = HPackContext(); + // Sets dynamic table to 4096 + expect(context.decoder.decode(TestHelper.newInteger(0x20, 5, 4096)), + []); + }); + + test('dynamic table entry', () { + List
headers; + var context = HPackContext(); + + var buffer = []; + buffer.addAll(TestHelper.insertIntoDynamicTable(2048, char0, charA)); + buffer.addAll(TestHelper.insertIntoDynamicTable(2048, char1, charB)); + buffer.addAll(TestHelper.dynamicTableLookup(0)); + buffer.addAll(TestHelper.dynamicTableLookup(1)); + buffer.addAll(TestHelper.dynamicTableLookup(0)); + buffer.addAll(TestHelper.dynamicTableLookup(1)); + buffer.addAll(TestHelper.insertIntoDynamicTable(1024, char2, charC)); + buffer.addAll(TestHelper.insertIntoDynamicTable(1024, char3, charD)); + buffer.addAll(TestHelper.dynamicTableLookup(0)); + buffer.addAll(TestHelper.dynamicTableLookup(1)); + buffer.addAll(TestHelper.dynamicTableLookup(2)); + + headers = context.decoder.decode(buffer); + expect(headers, hasLength(11)); + TestHelper.expectHeader(headers[0], 2048, char0, charA); + TestHelper.expectHeader(headers[1], 2048, char1, charB); + + TestHelper.expectHeader(headers[2], 2048, char1, charB); + TestHelper.expectHeader(headers[3], 2048, char0, charA); + TestHelper.expectHeader(headers[4], 2048, char1, charB); + TestHelper.expectHeader(headers[5], 2048, char0, charA); + + TestHelper.expectHeader(headers[6], 1024, char2, charC); + TestHelper.expectHeader(headers[7], 1024, char3, charD); + + TestHelper.expectHeader(headers[8], 1024, char1, charD); + TestHelper.expectHeader(headers[9], 1024, char0, charC); + TestHelper.expectHeader(headers[10], 2048, char1, charB); + + // We're reducing now the size by 1 byte, which should evict the last + // entry. + headers = + context.decoder.decode(TestHelper.setDynamicTableSize(4096 - 1)); + expect(headers, hasLength(0)); + + headers = context.decoder.decode(TestHelper.dynamicTableLookup(0)); + expect(headers, hasLength(1)); + TestHelper.expectHeader(headers[0], 1024, char1, charD); + + headers = context.decoder.decode(TestHelper.dynamicTableLookup(1)); + expect(headers, hasLength(1)); + TestHelper.expectHeader(headers[0], 1024, char0, charC); + + // Since we reduce the size by 1 byte, the last entry must be gone now. + expect(() => context.decoder.decode(TestHelper.dynamicTableLookup(2)), + throwsA(isHPackDecodingException)); + }); + }); + + group('encoder-tests', () { + test('simple-encoding', () { + var context = HPackContext(); + var headers = [Header.ascii('key', 'value')]; + expect(context.encoder.encode(headers), + [0x00, 0x03, 0x6b, 0x65, 0x79, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65]); + }); + + test('simple-encoding-long-value', () { + var context = HPackContext(); + var headers = [ + Header([0x42], List.filled(300, 0x84)) + ]; + + expect(context.decoder.decode(context.encoder.encode(headers)).first, + equalsHeader(headers.first)); + + expect(context.encoder.encode(headers), [ + // Literal Header Field with Incremental Indexing - Indexed Name + 0x00, + + // Key: Length + 0x01, + + // Key: Bytes + 0x42, + + // Value: (first 7 bits + rest) + 0x7f, 0xad, 0x01, + + // Value: Bytes + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, 0x84, + ]); + }); + }); + }); +} + +class TestHelper { + static List setDynamicTableSize(int newSize) { + return TestHelper.newInteger(0x20, 5, newSize); + } + + static List newInteger(int currentByte, int prefixBits, int value) { + assert((currentByte & ((1 << prefixBits) - 1)) == 0); + var buffer = []; + if (value < ((1 << prefixBits) - 1)) { + currentByte |= value; + buffer.add(currentByte); + } else { + // Length encodeded. + currentByte |= (1 << prefixBits) - 1; + value -= (1 << prefixBits) - 1; + buffer.add(currentByte); + var done = false; + while (!done) { + currentByte = value & 0x7f; + value = value >> 7; + done = value == 0; + if (!done) currentByte |= 0x80; + buffer.add(currentByte); + } + } + return buffer; + } + + static List insertIntoDynamicTable(int n, int nameChar, int valueChar) { + // NOTE: size(header) = 32 + header.name.length + header.value.length. + + var buffer = []; + + // Literal indexed (will be put into dynamic table) + buffer.addAll([0x40]); + + var name = [nameChar]; + buffer.addAll(newInteger(0, 7, name.length)); + buffer.addAll(name); + + var value = List.filled(n - 32 - name.length, valueChar); + buffer.addAll(newInteger(0, 7, value.length)); + buffer.addAll(value); + + return buffer; + } + + static List dynamicTableLookup(int index) { + // There are 62 entries in the static table. + return newInteger(0x80, 7, 62 + index); + } + + static void expectHeader(Header h, int len, int nameChar, int valueChar) { + var data = h.value; + expect(data, hasLength(len - 32 - 1)); + for (var i = 0; i < data.length; i++) { + expect(data[i], valueChar); + } + } +} + +/// A matcher for HuffmannDecodingExceptions. +const Matcher isHPackDecodingException = TypeMatcher(); + +class _HeaderMatcher extends Matcher { + final Header header; + + _HeaderMatcher(this.header); + + @override + Description describe(Description description) => description.add('Header'); + + @override + bool matches(Object? item, Map matchState) { + return item is Header && + _compareLists(item.name, header.name) && + _compareLists(item.value, header.value); + } + + bool _compareLists(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + +Matcher isHeader(String name, String value) => + _HeaderMatcher(Header.ascii(name, value)); + +Matcher equalsHeader(Header header) => _HeaderMatcher(header); diff --git a/pkgs/http2/test/src/hpack/huffman_table_test.dart b/pkgs/http2/test/src/hpack/huffman_table_test.dart new file mode 100644 index 0000000000..fa1c75be71 --- /dev/null +++ b/pkgs/http2/test/src/hpack/huffman_table_test.dart @@ -0,0 +1,158 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert' show ascii; + +import 'package:http2/src/hpack/huffman.dart'; +import 'package:http2/src/hpack/huffman_table.dart'; +import 'package:test/test.dart'; + +void main() { + group('hpack', () { + group('huffman', () { + final decode = http2HuffmanCodec.decode; + final encode = http2HuffmanCodec.encode; + + var hpackSpecTestCases = { + 'www.example.com': [ + 0xf1, + 0xe3, + 0xc2, + 0xe5, + 0xf2, + 0x3a, + 0x6b, + 0xa0, + 0xab, + 0x90, + 0xf4, + 0xff + ], + 'no-cache': [0xa8, 0xeb, 0x10, 0x64, 0x9c, 0xbf], + 'custom-key': [0x25, 0xa8, 0x49, 0xe9, 0x5b, 0xa9, 0x7d, 0x7f], + 'custom-value': [0x25, 0xa8, 0x49, 0xe9, 0x5b, 0xb8, 0xe8, 0xb4, 0xbf], + }; + + test('hpack-spec-testcases', () { + hpackSpecTestCases.forEach((String value, List encoding) { + expect(decode(encoding), ascii.encode(value)); + expect(encode(ascii.encode(value)), encoding); + }); + }); + + test('more-than-7-bit-padding', () { + var data = [ + // Just more-than-7-bitpadding + [0xff], + [0xff, 0xff], + [0xff, 0xff, 0xff], + [0xff, 0xff, 0xff, 0xff], + + // 0xf8 = '&' + more-than-7-bitpadding + [0xf8, 0xff], + [0xf8, 0xff, 0xff], + [0xf8, 0xff, 0xff, 0xff], + [0xf8, 0xff, 0xff, 0xff, 0xff], + + // ')' + entire EOS + [0xfe, 0xff, 0xff, 0xff, 0xff], + ]; + + for (var test in data) { + expect(() => decode(test), throwsA(isHuffmanDecodingException)); + } + }); + + test('incomplete-encoding', () { + var data = [ + // Incomplete encoding + [0xfe], + + // 0xf8 = '&' + Incomplete encoding + [0xf8, 0xfe], + ]; + + for (var test in data) { + expect(() => decode(test), throwsA(isHuffmanDecodingException)); + } + }); + + test('fuzzy-test', () { + var data = [ + [0xb8, 0xa4, 0x4e, 0xe3, 0xb1, 0x4d, 0x3d, 0x63, 0x16, 0x5b, 0x6a], + [0x71, 0x5f, 0xb3, 0xb1, 0x4b, 0x94, 0xe8, 0x2f, 0x4c, 0x3d, 0x04], + [0x95, 0x6d, 0x89, 0xfb, 0x91, 0x6a, 0x6c, 0x52, 0x64, 0x9a, 0xd1], + [0x64, 0x59, 0x79, 0x38, 0xd2, 0x09, 0xea, 0x94, 0x92, 0xda, 0x24], + [0xb0, 0x35, 0xfe, 0xa9, 0x96, 0xb5, 0xe1, 0xde, 0x0a, 0x82, 0x18], + [0x39, 0xe5, 0xdd, 0xba, 0x50, 0xd4, 0x33, 0xa7, 0xb9, 0x63, 0x21], + [0x26, 0x52, 0x7a, 0xaa, 0x52, 0x4d, 0x27, 0x81, 0xe4, 0xef, 0xcd], + [0x17, 0x9e, 0x09, 0xcc, 0xd0, 0x0f, 0x5e, 0x03, 0x45, 0xc9, 0xba], + [0x84, 0xfc, 0x75, 0xeb, 0xcc, 0x9e, 0xb6, 0x50, 0x3f, 0xf8, 0x00], + [0xb9, 0x24, 0x95, 0x13, 0x6d, 0x89, 0xb2, 0x89, 0x86, 0x02, 0xca], + [0xb7, 0xd5, 0x78, 0xfa, 0xa3, 0xa9, 0x90, 0x1b, 0x35, 0xb4, 0x72], + [0x62, 0x9a, 0x31, 0x0c, 0x32, 0x1c, 0x25, 0x2e, 0x1b, 0x56, 0x55], + [0xa9, 0x5d, 0xa8, 0xa4, 0xed, 0x91, 0xeb, 0xba, 0xa0, 0xf9, 0x82], + [0x59, 0x9c, 0xc3, 0x6f, 0x66, 0xec, 0x65, 0xe0, 0x95, 0x6e, 0x34], + [0x3d, 0xc7, 0x0d, 0x6c, 0x01, 0x7d, 0xf2, 0x03, 0x9b, 0xe3, 0xc1], + [0x1d, 0xc6, 0xa4, 0xd1, 0x59, 0x52, 0xce, 0x42, 0x3d, 0xf6, 0xe5], + [0x2d, 0xbd, 0xb6, 0x5c, 0xfb, 0x52, 0x65, 0x2e, 0x7f, 0x03, 0x61], + [0x22, 0x24, 0x50, 0x48, 0x65, 0x5a, 0xe0, 0x0d, 0xf9, 0x78, 0x8d], + [0x72, 0xeb, 0x1d, 0x31, 0xb7, 0xe3, 0xa8, 0x15, 0x1f, 0xf1, 0x43], + [0x45, 0xa4, 0x40, 0x5a, 0x9c, 0x98, 0xa8, 0x6e, 0xac, 0xba, 0x83], + [0x27, 0x55, 0x33, 0xa7, 0x79, 0x08, 0x29, 0x42, 0x6d, 0x89, 0xfc], + [0x3b, 0x65, 0x21, 0x7a, 0x24, 0x58, 0x58, 0x6a, 0x97, 0x6e, 0x7c], + [0x56, 0x41, 0xff, 0x08, 0xaf, 0x9d, 0x33, 0x12, 0xcd, 0xb5, 0x99], + [0x35, 0x48, 0x38, 0x46, 0x3f, 0xee, 0x15, 0x16, 0x8d, 0xf5, 0x16], + [0xcc, 0xc0, 0x1b, 0x1e, 0xf1, 0xae, 0xf7, 0x40, 0xca, 0xc7, 0x9d], + [0x93, 0xae, 0x93, 0xcf, 0x97, 0xdf, 0xba, 0xd6, 0xb2, 0xac, 0x2f], + [0x45, 0xe4, 0x5b, 0x73, 0x54, 0x4c, 0x6c, 0x95, 0xa9, 0xab, 0x7f], + [0x71, 0xac, 0xbf, 0xdf, 0xa4, 0x29, 0xe3, 0x17, 0x3f, 0x24, 0x2f], + [0x5e, 0xc0, 0xf2, 0xbf, 0x5d, 0xc0, 0x31, 0x2d, 0x97, 0x24, 0x1d], + [0x6d, 0x0b, 0x7c, 0x15, 0x68, 0x7c, 0xe1, 0x15, 0xbf, 0x4f, 0x85], + [0x0a, 0x59, 0xf2, 0x3e, 0x48, 0x1d, 0xac, 0xc8, 0x22, 0xb0, 0x37], + [0x3a, 0xe2, 0x9e, 0xec, 0xf9, 0x1e, 0x88, 0xfa, 0xbe, 0x00, 0xee], + [0xc7, 0x5a, 0x1f, 0xc8, 0x48, 0x23, 0x3b, 0x1a, 0x0f, 0xf3, 0x7c], + [0x43, 0x0d, 0x10, 0x03, 0xb2, 0xc6, 0xbd, 0xed, 0x03, 0x19, 0x49], + [0xc9, 0xc4, 0x0e, 0xf3, 0xc6, 0xf4, 0xc1, 0x71, 0xee, 0x96, 0xeb], + [0x18, 0x51, 0x07, 0x36, 0x1a, 0x13, 0x83, 0x69, 0x2b, 0x1b, 0x09], + [0xac, 0x23, 0xb7, 0x47, 0x2d, 0xeb, 0x39, 0xdc, 0x3e, 0xdb, 0x74], + [0x44, 0x60, 0x06, 0x28, 0x5e, 0x8f, 0xef, 0xfc, 0x70, 0x7b, 0x73], + [0xda, 0x38, 0x25, 0x76, 0xa9, 0x1a, 0x99, 0x9a, 0x52, 0xdf, 0x8c], + [0xd4, 0xc4, 0x99, 0x2b, 0x54, 0x88, 0xc9, 0x34, 0x80, 0x43, 0x15], + [0x11, 0xa1, 0xed, 0xe3, 0xb4, 0x88, 0xd5, 0x1d, 0x4a, 0x1b, 0x9f], + [0xfd, 0x2c, 0xb4, 0x6e, 0x65, 0xfb, 0x27, 0x9b, 0x65, 0x55, 0x19], + [0xb6, 0xa4, 0x67, 0x16, 0x8a, 0x59, 0xf5, 0xfc, 0x0f, 0x7e, 0x24], + [0x40, 0x8e, 0x5d, 0x84, 0x90, 0x76, 0x50, 0xdb, 0x72, 0x2a, 0x3b], + [0x7d, 0x1e, 0x9d, 0x2f, 0xad, 0xce, 0x60, 0x00, 0xf8, 0xbc, 0xfa], + [0xc1, 0x2d, 0x32, 0xbd, 0xa2, 0xe7, 0xed, 0x17, 0x48, 0xca, 0xb0], + [0xe6, 0x91, 0x6c, 0xa7, 0xdc, 0x83, 0x58, 0x19, 0x05, 0xb1, 0xa6], + [0xec, 0xb2, 0x16, 0xa3, 0x89, 0x7a, 0xcd, 0x44, 0xe9, 0x3a, 0x98], + [0xcf, 0xef, 0x78, 0x5b, 0x7a, 0xec, 0xa8, 0xfa, 0x6c, 0x78, 0x23], + [0x8b, 0x53, 0x89, 0x82, 0x21, 0x3e, 0xfc, 0xed, 0xe4, 0x6b, 0xa0], + [0xff, 0x28, 0x10, 0xb2, 0x24, 0xf9, 0xb5, 0x3e, 0x08, 0xb2, 0x50], + [0x5e, 0x57, 0x11, 0xff, 0x06, 0x1b, 0xc7, 0x0b, 0x28, 0x5b, 0x34], + [0x00, 0x4a, 0xcc, 0x4e, 0x8e, 0x07, 0xea, 0x93, 0x10, 0x1c, 0x87], + [0xab, 0xc7, 0x7e, 0x10, 0x64, 0x7f, 0xa4, 0x6c, 0xca, 0x93, 0x73], + [0xcf, 0x57, 0xc5, 0x15, 0xbc, 0x47, 0xed, 0x5b, 0x1e, 0xb5, 0x9b], + [0x8e, 0xa5, 0xf3, 0x07, 0xa0, 0x68, 0x1e, 0x9e, 0xea, 0x57, 0x3f], + [0xfe, 0xa7, 0x7f, 0x91, 0xc7, 0xa4, 0x15, 0x7c, 0xa2, 0x00, 0x4c], + [0xb9, 0x62, 0x28, 0xa5, 0x9b, 0x04, 0x98, 0xf9, 0xdd, 0x37, 0x42], + [0xfa, 0x40, 0x1c, 0xce, 0xa0, 0x75, 0x9d, 0xaf, 0xd2, 0x09, 0xae], + [0xa7, 0x8e, 0xdb, 0x1e, 0x8b, 0x94, 0x24, 0x47, 0xd8, 0x04, 0xd7], + [0x69, 0x95, 0x8a, 0x29, 0xbe, 0x9f, 0xfb, 0x71, 0x91, 0x9a, 0x40], + [0x82, 0xed, 0x1e, 0xf5, 0xac, 0x34, 0x17, 0xfe, 0x5f, 0xfd, 0xd3], + [0x81, 0xe6, 0xaa, 0x7b, 0x12, 0xf0, 0xb2, 0xb9, 0x47, 0x02, 0x3c], + [0x05, 0xc3, 0x6d, 0xd5, 0xf1, 0xa4, 0x93, 0xe2, 0x8b, 0x7c, 0xed], + ]; + for (var test in data) { + expect(decode(encode(test)), equals(test)); + } + }); + }); + }); +} + +/// A matcher for HuffmanDecodingExceptions. +const Matcher isHuffmanDecodingException = + TypeMatcher(); diff --git a/pkgs/http2/test/src/ping/ping_handler_test.dart b/pkgs/http2/test/src/ping/ping_handler_test.dart new file mode 100644 index 0000000000..df02420870 --- /dev/null +++ b/pkgs/http2/test/src/ping/ping_handler_test.dart @@ -0,0 +1,125 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/ping/ping_handler.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../error_matchers.dart'; + +void main() { + group('ping-handler', () { + test('successful-ping', () async { + var writer = FrameWriterMock(); + var pingHandler = instantiateHandler(writer); + + var p1 = pingHandler.ping(); + var p2 = pingHandler.ping(); + + verifyInOrder([ + writer.writePingFrame(1), + writer.writePingFrame(2), + ]); + + var header = FrameHeader(8, FrameType.PING, PingFrame.FLAG_ACK, 0); + pingHandler.processPingFrame(PingFrame(header, 1)); + var header2 = FrameHeader(8, FrameType.PING, PingFrame.FLAG_ACK, 0); + pingHandler.processPingFrame(PingFrame(header2, 2)); + + await p1; + await p2; + verifyNoMoreInteractions(writer); + }); + + test('successful-ack-to-remote-ping', () async { + var writer = FrameWriterMock(); + var pingHandler = instantiateHandler(writer); + + var header = FrameHeader(8, FrameType.PING, 0, 0); + pingHandler.processPingFrame(PingFrame(header, 1)); + var header2 = FrameHeader(8, FrameType.PING, 0, 0); + pingHandler.processPingFrame(PingFrame(header2, 2)); + + verifyInOrder([ + writer.writePingFrame(1, ack: true), + writer.writePingFrame(2, ack: true) + ]); + verifyNoMoreInteractions(writer); + }); + + test('ping-unknown-opaque-data', () async { + var writer = FrameWriterMock(); + var pingHandler = instantiateHandler(writer); + + var future = pingHandler.ping(); + verify(writer.writePingFrame(1)).called(1); + + var header = FrameHeader(8, FrameType.PING, PingFrame.FLAG_ACK, 0); + expect(() => pingHandler.processPingFrame(PingFrame(header, 2)), + throwsA(isProtocolException)); + + // Ensure outstanding pings will be completed with an error once we call + // `pingHandler.terminate()`. + unawaited(future.catchError(expectAsync2((Object error, Object _) { + expect(error, 'hello world'); + }))); + pingHandler.terminate('hello world'); + verifyNoMoreInteractions(writer); + }); + + test('terminate-ping-handler', () async { + var writer = FrameWriterMock(); + var pingHandler = instantiateHandler(writer); + + pingHandler.terminate('hello world'); + expect( + () => pingHandler.processPingFrame(PingFrame( + FrameHeader(8, FrameType.PING, PingFrame.FLAG_ACK, 1), 1)), + throwsA(isTerminatedException)); + expect(pingHandler.ping(), throwsA(isTerminatedException)); + verifyZeroInteractions(writer); + }); + + test('ping-non-zero-stream-id', () async { + var writer = FrameWriterMock(); + var pingHandler = instantiateHandler(writer); + + var header = FrameHeader(8, FrameType.PING, PingFrame.FLAG_ACK, 1); + expect(() => pingHandler.processPingFrame(PingFrame(header, 1)), + throwsA(isProtocolException)); + verifyZeroInteractions(writer); + }); + + test('receiving-ping-calls-stream', () async { + final pings = []; + + final writer = FrameWriterMock(); + final pingStream = StreamController()..stream.listen(pings.add); + + PingHandler(writer, pingStream) + ..processPingFrame(PingFrame( + FrameHeader(8, FrameType.PING, 0, 0), + 1, + )) + ..processPingFrame(PingFrame( + FrameHeader(8, FrameType.PING, 0, 0), + 2, + )); + + await pingStream.close(); + + expect(pings, [1, 2]); + }); + }); +} + +PingHandler instantiateHandler(FrameWriterMock writer) { + var controller = StreamController(); + return PingHandler(writer, controller); +} + +class FrameWriterMock extends Mock implements FrameWriter {} diff --git a/pkgs/http2/test/src/settings/settings_handler_test.dart b/pkgs/http2/test/src/settings/settings_handler_test.dart new file mode 100644 index 0000000000..3d16a73ae8 --- /dev/null +++ b/pkgs/http2/test/src/settings/settings_handler_test.dart @@ -0,0 +1,112 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:http2/src/frames/frames.dart'; +import 'package:http2/src/hpack/hpack.dart'; +import 'package:http2/src/settings/settings.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import '../error_matchers.dart'; + +void main() { + group('settings-handler', () { + var pushSettings = [Setting(Setting.SETTINGS_ENABLE_PUSH, 0)]; + var invalidPushSettings = [Setting(Setting.SETTINGS_ENABLE_PUSH, 2)]; + var setMaxTable256 = [Setting(Setting.SETTINGS_HEADER_TABLE_SIZE, 256)]; + + test('successful-setting', () async { + var writer = FrameWriterMock(); + var sh = SettingsHandler( + HPackEncoder(), writer, ActiveSettings(), ActiveSettings()); + + // Start changing settings. + var changed = sh.changeSettings(pushSettings); + verify(writer.writeSettingsFrame(pushSettings)).called(1); + verifyNoMoreInteractions(writer); + + // Check that settings haven't been applied. + expect(sh.acknowledgedSettings.enablePush, true); + + // Simulate remote end to respond with an ACK. + var header = + FrameHeader(0, FrameType.SETTINGS, SettingsFrame.FLAG_ACK, 0); + sh.handleSettingsFrame(SettingsFrame(header, [])); + + await changed; + + // Check that settings have been applied. + expect(sh.acknowledgedSettings.enablePush, false); + }); + + test('ack-remote-settings-change', () { + var writer = FrameWriterMock(); + var sh = SettingsHandler( + HPackEncoder(), writer, ActiveSettings(), ActiveSettings()); + + // Check that settings haven't been applied. + expect(sh.peerSettings.enablePush, true); + + // Simulate remote end by setting the push setting. + var header = FrameHeader(6, FrameType.SETTINGS, 0, 0); + sh.handleSettingsFrame(SettingsFrame(header, pushSettings)); + + // Check that settings have been applied. + expect(sh.peerSettings.enablePush, false); + verify(writer.writeSettingsAckFrame()).called(1); + verifyNoMoreInteractions(writer); + }); + + test('invalid-remote-ack', () { + var writer = FrameWriterMock(); + var sh = SettingsHandler( + HPackEncoder(), writer, ActiveSettings(), ActiveSettings()); + + // Simulates ACK even though we haven't sent any settings. + var header = + FrameHeader(0, FrameType.SETTINGS, SettingsFrame.FLAG_ACK, 0); + var settingsFrame = SettingsFrame(header, const []); + + expect(() => sh.handleSettingsFrame(settingsFrame), + throwsA(isProtocolException)); + verifyZeroInteractions(writer); + }); + + test('invalid-remote-settings-change', () { + var writer = FrameWriterMock(); + var sh = SettingsHandler( + HPackEncoder(), writer, ActiveSettings(), ActiveSettings()); + + // Check that settings haven't been applied. + expect(sh.peerSettings.enablePush, true); + + // Simulate remote end by setting the push setting. + var header = FrameHeader(6, FrameType.SETTINGS, 0, 0); + var settingsFrame = SettingsFrame(header, invalidPushSettings); + expect(() => sh.handleSettingsFrame(settingsFrame), + throwsA(isProtocolException)); + verifyZeroInteractions(writer); + }); + + test('change-max-header-table-size', () { + var writer = FrameWriterMock(); + var mock = HPackEncoderMock(); + var sh = + SettingsHandler(mock, writer, ActiveSettings(), ActiveSettings()); + + // Simulate remote end by setting the push setting. + var header = FrameHeader(6, FrameType.SETTINGS, 0, 0); + var settingsFrame = SettingsFrame(header, setMaxTable256); + sh.handleSettingsFrame(settingsFrame); + verify(mock.updateMaxSendingHeaderTableSize(256)).called(1); + verify(writer.writeSettingsAckFrame()).called(1); + verifyNoMoreInteractions(mock); + verifyNoMoreInteractions(writer); + }); + }); +} + +class FrameWriterMock extends Mock implements FrameWriter {} + +class HPackEncoderMock extends Mock implements HPackEncoder {} diff --git a/pkgs/http2/test/src/streams/helper.dart b/pkgs/http2/test/src/streams/helper.dart new file mode 100644 index 0000000000..726a7d6449 --- /dev/null +++ b/pkgs/http2/test/src/streams/helper.dart @@ -0,0 +1,50 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +void expectHeadersEqual(List
headers, List
expectedHeaders) { + expect(headers, hasLength(expectedHeaders.length)); + for (var i = 0; i < expectedHeaders.length; i++) { + expect(headers[i].name, expectedHeaders[i].name); + expect(headers[i].value, expectedHeaders[i].value); + } +} + +void expectEmptyStream(Stream s) { + s.listen(expectAsync1((_) {}, count: 0), onDone: expectAsync0(() {})); +} + +void streamTest( + String name, + Future Function(ClientTransportConnection, ServerTransportConnection) + func, + {ClientSettings? settings}) { + return test(name, () { + var bidirect = BidirectionalConnection(); + bidirect.settings = settings; + var client = bidirect.clientConnection; + var server = bidirect.serverConnection; + return func(client, server); + }); +} + +class BidirectionalConnection { + ClientSettings? settings; + final StreamController> writeA = StreamController(); + final StreamController> writeB = StreamController(); + + Stream> get readA => writeA.stream; + + Stream> get readB => writeB.stream; + + ClientTransportConnection get clientConnection => + ClientTransportConnection.viaStreams(readA, writeB, settings: settings); + + ServerTransportConnection get serverConnection => + ServerTransportConnection.viaStreams(readB, writeA); +} diff --git a/pkgs/http2/test/src/streams/simple_flow_test.dart b/pkgs/http2/test/src/streams/simple_flow_test.dart new file mode 100644 index 0000000000..25226d1a60 --- /dev/null +++ b/pkgs/http2/test/src/streams/simple_flow_test.dart @@ -0,0 +1,96 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import 'helper.dart'; + +void main() { + group('streams', () { + group('flowcontrol', () { + const numOfOneKB = 1000; + + var expectedHeaders = [Header.ascii('key', 'value')]; + var allBytes = List.generate(numOfOneKB * 1024, (i) => i % 256); + allBytes.addAll(List.generate(42, (i) => 42)); + + void Function(StreamMessage) headersTestFun(String type) { + return expectAsync1((StreamMessage msg) { + expect( + msg, + isA() + .having((m) => m.headers.first.name, 'headers.first.name', + expectedHeaders.first.name) + .having((m) => m.headers.first.value, 'headers.first.value', + expectedHeaders.first.value)); + }); + } + + var serverReceivedAllBytes = Completer(); + + void Function(StreamMessage) messageTestFun(String type) { + var expectHeader = true; + var numBytesReceived = 0; + return (StreamMessage msg) { + if (expectHeader) { + expectHeader = false; + expect( + msg, + isA() + .having((m) => m.headers.first.name, 'headers.first.name', + expectedHeaders.first.name) + .having((m) => m.headers.first.value, 'headers.first.value', + expectedHeaders.first.value)); + } else { + expect(msg, isA()); + var bytes = (msg as DataStreamMessage).bytes; + expect( + bytes, + allBytes.sublist( + numBytesReceived, numBytesReceived + bytes.length)); + numBytesReceived += bytes.length; + + if (numBytesReceived > allBytes.length) { + if (serverReceivedAllBytes.isCompleted) { + throw Exception('Got more messages than expected'); + } + serverReceivedAllBytes.complete(); + } + } + }; + } + + void sendData(TransportStream cStream) { + for (var i = 0; i < (allBytes.length + 1023) ~/ 1024; i++) { + var end = 1024 * (i + 1); + var isLast = end > allBytes.length; + if (isLast) { + end = allBytes.length; + } + cStream.sendData(allBytes.sublist(1024 * i, end), endStream: isLast); + } + } + + streamTest('single-header-request--empty-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + server.incomingStreams + .listen(expectAsync1((TransportStream sStream) async { + sStream.incomingMessages + .listen(messageTestFun('server'), onDone: expectAsync0(() {})); + sStream.sendHeaders(expectedHeaders, endStream: true); + await serverReceivedAllBytes.future; + })); + + TransportStream cStream = client.makeRequest(expectedHeaders); + sendData(cStream); + cStream.incomingMessages + .listen(headersTestFun('client'), onDone: expectAsync0(() {})); + }); + }); + }); +} diff --git a/pkgs/http2/test/src/streams/simple_push_test.dart b/pkgs/http2/test/src/streams/simple_push_test.dart new file mode 100644 index 0000000000..d12dbb5d5d --- /dev/null +++ b/pkgs/http2/test/src/streams/simple_push_test.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert' show utf8; + +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import 'helper.dart'; + +void main() { + group('streams', () { + group('server-push', () { + const numOfOneKB = 1000; + + var expectedHeaders = [Header.ascii('key', 'value')]; + var allBytes = List.generate(numOfOneKB * 1024, (i) => i % 256); + allBytes.addAll(List.generate(42, (i) => 42)); + + void testHeaders(List
headers) { + expect(headers, hasLength(expectedHeaders.length)); + for (var i = 0; i < headers.length; i++) { + expect(headers[i].name, expectedHeaders[i].name); + expect(headers[i].value, expectedHeaders[i].value); + } + } + + void Function(StreamMessage) headersTestFun() { + return expectAsync1((StreamMessage msg) { + expect(msg, isA()); + testHeaders((msg as HeadersStreamMessage).headers); + }); + } + + var serverReceivedAllBytes = Completer(); + + Future readData(StreamIterator iterator) async { + var all = []; + + while (await iterator.moveNext()) { + var msg = iterator.current; + expect(msg, isA()); + all.addAll((msg as DataStreamMessage).bytes); + } + + return utf8.decode(all); + } + + Future sendData(TransportStream stream, String data) { + stream.outgoingMessages + ..add(DataStreamMessage(utf8.encode(data))) + ..close(); + return stream.outgoingMessages.done; + } + + streamTest('server-push', (ClientTransportConnection client, + ServerTransportConnection server) async { + server.incomingStreams + .listen(expectAsync1((ServerTransportStream sStream) async { + var pushStream = sStream.push(expectedHeaders); + pushStream.sendHeaders(expectedHeaders); + await sendData(pushStream, 'pushing "hello world" :)'); + + unawaited(sStream.incomingMessages.drain()); + sStream.sendHeaders(expectedHeaders, endStream: true); + + await serverReceivedAllBytes.future; + })); + + var cStream = client.makeRequest(expectedHeaders, endStream: true); + cStream.incomingMessages + .listen(headersTestFun(), onDone: expectAsync0(() {})); + cStream.peerPushes + .listen(expectAsync1((TransportStreamPush push) async { + testHeaders(push.requestHeaders); + + var iterator = StreamIterator(push.stream.incomingMessages); + var hasNext = await iterator.moveNext(); + expect(hasNext, isTrue); + testHeaders((iterator.current as HeadersStreamMessage).headers); + + var msg = await readData(iterator); + expect(msg, 'pushing "hello world" :)'); + })); + }, settings: const ClientSettings(allowServerPushes: true)); + }); + }); +} diff --git a/pkgs/http2/test/src/streams/streams_test.dart b/pkgs/http2/test/src/streams/streams_test.dart new file mode 100644 index 0000000000..6bfa49c495 --- /dev/null +++ b/pkgs/http2/test/src/streams/streams_test.dart @@ -0,0 +1,238 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import 'helper.dart'; + +void main() { + group('streams', () { + streamTest('single-header-request--empty-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) { + sStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }), onDone: expectAsync0(() {})); + sStream.outgoingMessages.close(); + })); + + TransportStream cStream = + client.makeRequest(expectedHeaders, endStream: true); + expectEmptyStream(cStream.incomingMessages); + }); + + streamTest('multi-header-request--empty-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) { + sStream.incomingMessages.listen( + expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }, count: 3), + onDone: expectAsync0(() {})); + sStream.outgoingMessages.close(); + })); + + TransportStream cStream = client.makeRequest(expectedHeaders); + cStream.sendHeaders(expectedHeaders); + cStream.sendHeaders(expectedHeaders, endStream: true); + expectEmptyStream(cStream.incomingMessages); + }); + + streamTest('multi-data-request--empty-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + var chunks = [ + [1], + [2], + [3] + ]; + + server.incomingStreams + .listen(expectAsync1((TransportStream sStream) async { + var isFirst = true; + var receivedChunks = >[]; + sStream.incomingMessages.listen( + expectAsync1((StreamMessage msg) { + if (isFirst) { + isFirst = false; + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + } else { + expect(msg, isA()); + + var dataMsg = msg as DataStreamMessage; + receivedChunks.add(dataMsg.bytes); + } + }, count: 1 + chunks.length), onDone: expectAsync0(() { + expect(receivedChunks, chunks); + })); + unawaited(sStream.outgoingMessages.close()); + })); + + TransportStream cStream = client.makeRequest(expectedHeaders); + chunks.forEach(cStream.sendData); + unawaited(cStream.outgoingMessages.close()); + expectEmptyStream(cStream.incomingMessages); + }); + + streamTest('single-header-request--single-headers-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) { + sStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }), onDone: expectAsync0(() {})); + sStream.sendHeaders(expectedHeaders, endStream: true); + })); + + TransportStream cStream = + client.makeRequest(expectedHeaders, endStream: true); + + cStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }), onDone: expectAsync0(() {})); + }); + + streamTest('single-header-request--multi-headers-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) { + sStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }), onDone: expectAsync0(() {})); + + sStream.sendHeaders(expectedHeaders); + sStream.sendHeaders(expectedHeaders); + sStream.sendHeaders(expectedHeaders, endStream: true); + })); + + TransportStream cStream = + client.makeRequest(expectedHeaders, endStream: true); + + cStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }, count: 3)); + }); + + streamTest('single-header-request--multi-data-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + var chunks = [ + [1], + [2], + [3] + ]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) { + sStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect(msg, isA()); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + }), onDone: expectAsync0(() {})); + + chunks.forEach(sStream.sendData); + sStream.outgoingMessages.close(); + })); + + TransportStream cStream = client.makeRequest(expectedHeaders); + unawaited(cStream.outgoingMessages.close()); + + var i = 0; + cStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + expect( + msg, + isA() + .having((m) => m.bytes, 'bytes', chunks[i++])); + }, count: chunks.length)); + }); + }); + + streamTest('single-data-request--data-trailer-response', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var expectedHeaders = [Header.ascii('key', 'value')]; + var chunk = [1]; + + server.incomingStreams.listen(expectAsync1((TransportStream sStream) async { + var isFirst = true; + List? receivedChunk; + sStream.incomingMessages.listen( + expectAsync1((StreamMessage msg) { + if (isFirst) { + isFirst = false; + expect(msg, isA()); + expect(msg.endStream, false); + + var headersMsg = msg as HeadersStreamMessage; + expectHeadersEqual(headersMsg.headers, expectedHeaders); + } else { + expect(msg, isA()); + expect(msg.endStream, true); + expect(receivedChunk, null); + + var dataMsg = msg as DataStreamMessage; + receivedChunk = dataMsg.bytes; + } + }, count: 2), onDone: expectAsync0(() { + expect(receivedChunk, chunk); + sStream.sendData([2]); + sStream.sendHeaders(expectedHeaders, endStream: true); + })); + })); + + TransportStream cStream = client.makeRequest(expectedHeaders); + cStream.sendData(chunk, endStream: true); + + var isFirst = true; + cStream.incomingMessages.listen(expectAsync1((StreamMessage msg) { + if (isFirst) { + expect(msg, const TypeMatcher()); + final data = msg as DataStreamMessage; + expect(data.bytes, [2]); + isFirst = false; + } else { + expect(msg, const TypeMatcher()); + final trailer = msg as HeadersStreamMessage; + expect(trailer.endStream, true); + expectHeadersEqual(trailer.headers, expectedHeaders); + } + }, count: 2)); + }); +} diff --git a/pkgs/http2/test/transport_test.dart b/pkgs/http2/test/transport_test.dart new file mode 100644 index 0000000000..96c2c03e0c --- /dev/null +++ b/pkgs/http2/test/transport_test.dart @@ -0,0 +1,544 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE FILE. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:http2/src/flowcontrol/window.dart'; +import 'package:http2/transport.dart'; +import 'package:test/test.dart'; + +import 'src/hpack/hpack_test.dart' show isHeader; + +void main() { + group('transport-test', () { + transportTest('ping', + (TransportConnection client, TransportConnection server) async { + await client.ping(); + await server.ping(); + }); + + transportTest('terminated-client-ping', + (TransportConnection client, TransportConnection server) async { + var clientError = + client.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + })); + await client.terminate(); + await clientError; + + // NOTE: Now the connection is dead and client/server should complete + // with [TransportException]s when doing work (e.g. ping). + unawaited(client.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + }))); + unawaited(server.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + }))); + }); + + transportTest('terminated-server-ping', + (TransportConnection client, TransportConnection server) async { + var clientError = + client.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + })); + await server.terminate(); + await clientError; + + // NOTE: Now the connection is dead and the client/server should complete + // with [TransportException]s when doing work (e.g. ping). + unawaited(client.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + }))); + unawaited(server.ping().catchError(expectAsync2((Object e, StackTrace s) { + expect(e, isA()); + }))); + }); + + const concurrentStreamLimit = 5; + transportTest('exhaust-concurrent-stream-limit', + (ClientTransportConnection client, + ServerTransportConnection server) async { + Future clientFun() async { + // We have to wait until the max-concurrent-streams [Setting] was + // transferred from server to client, which is asynchronous. + // The default is unlimited, which is why we have to wait for the server + // setting to arrive on the client. + // At the moment, delaying by 2 microtask cycles is enough. + await Future.value(); + await Future.value(); + + final streams = []; + for (var i = 0; i < concurrentStreamLimit; ++i) { + expect(client.isOpen, true); + streams.add(client.makeRequest([Header.ascii('a', 'b')])); + } + expect(client.isOpen, false); + for (final stream in streams) { + stream.sendData([], endStream: true); + } + await client.finish(); + } + + Future serverFun() async { + await for (final stream in server.incomingStreams) { + await stream.incomingMessages.toList(); + stream.sendHeaders([Header.ascii('a', 'b')], endStream: true); + } + await server.finish(); + } + + await Future.wait([clientFun(), serverFun()]); + }, + serverSettings: + const ServerSettings(concurrentStreamLimit: concurrentStreamLimit)); + + transportTest('disabled-push', (ClientTransportConnection client, + ServerTransportConnection server) async { + server.incomingStreams + .listen(expectAsync1((ServerTransportStream stream) async { + expect(stream.canPush, false); + expect(() => stream.push([Header.ascii('a', 'b')]), + throwsA(const TypeMatcher())); + stream.sendHeaders([Header.ascii('x', 'y')], endStream: true); + })); + + var stream = + client.makeRequest([Header.ascii('a', 'b')], endStream: true); + + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + expect(messages[0] is HeadersStreamMessage, true); + expect( + (messages[0] as HeadersStreamMessage).headers[0], isHeader('x', 'y')); + + expect(await stream.peerPushes.toList(), isEmpty); + }); + + // By default, the stream concurrency level is set to this limit. + const kDefaultStreamLimit = 100; + transportTest('enabled-push-100', (ClientTransportConnection client, + ServerTransportConnection server) async { + // To ensure the limit is kept up-to-date with closing/opening streams, we + // retry this. + const kRepetitions = 20; + + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + var pushes = []; + for (var i = 0; i < kDefaultStreamLimit; i++) { + expect(stream.canPush, true); + pushes.add(stream.push([Header.ascii('a', 'b')])); + } + + // Now we should have reached the limit and we should not be able to + // create more pushes. + expect(stream.canPush, false); + expect(() => stream.push([Header.ascii('a', 'b')]), + throwsA(const TypeMatcher())); + + // Finish the pushes + for (var pushedStream in pushes) { + pushedStream + .sendHeaders([Header.ascii('e', 'nd')], endStream: true); + await pushedStream.incomingMessages.toList(); + } + + // Finish the stream. + stream.sendHeaders([Header.ascii('x', 'y')], endStream: true); + expect(await stream.incomingMessages.toList(), hasLength(1)); + } + } + + Future clientFun() async { + for (var i = 0; i < kRepetitions; i++) { + var stream = + client.makeRequest([Header.ascii('a', 'b')], endStream: true); + + Future expectPeerPushes() async { + var numberOfPushes = 0; + await for (TransportStreamPush pushedStream in stream.peerPushes) { + numberOfPushes++; + var messages = + await pushedStream.stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + expect((messages[0] as HeadersStreamMessage).headers[0], + isHeader('e', 'nd')); + expect(await pushedStream.stream.peerPushes.toList(), isEmpty); + } + return numberOfPushes; + } + + // Wait for the end of the normal stream. + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + expect(messages[0] is HeadersStreamMessage, true); + expect((messages[0] as HeadersStreamMessage).headers[0], + isHeader('x', 'y')); + + expect(await expectPeerPushes(), kDefaultStreamLimit); + } + } + + var serverFuture = serverFun(); + + await clientFun(); + await client.terminate(); + await serverFuture; + }, + clientSettings: const ClientSettings( + concurrentStreamLimit: kDefaultStreamLimit, + allowServerPushes: true)); + + transportTest('early-shutdown', (ClientTransportConnection client, + ServerTransportConnection server) async { + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.sendHeaders([Header.ascii('x', 'y')], endStream: true); + expect(await stream.incomingMessages.toList(), hasLength(1)); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + var stream = client.makeRequest(headers, endStream: true); + var finishFuture = client.finish(); + var messages = await stream.incomingMessages.toList(); + expect(messages, hasLength(1)); + await finishFuture; + } + + await Future.wait([serverFun(), clientFun()]); + }); + + transportTest('client-terminates-stream', (ClientTransportConnection client, + ServerTransportConnection server) async { + var readyForError = Completer(); + + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.sendHeaders([Header.ascii('x', 'y')], endStream: true); + stream.incomingMessages.listen(expectAsync1((msg) { + expect(msg, isA()); + readyForError.complete(); + }), onError: expectAsync1((Object error) { + expect('$error', contains('Stream was terminated by peer')); + })); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + var stream = client.makeRequest(headers, endStream: false); + await readyForError.future; + stream.terminate(); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + transportTest('server-terminates-stream', (ClientTransportConnection client, + ServerTransportConnection server) async { + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.terminate(); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + var stream = client.makeRequest(headers, endStream: true); + var messageList = stream.incomingMessages.toList(); + await messageList.catchError(expectAsync1((Object error) { + expect('$error', contains('Stream was terminated by peer')); + return []; + })); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + transportTest('client-terminates-stream-after-half-close', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var readyForError = Completer(); + + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.onTerminated = expectAsync1((Object? errorCode) { + expect(errorCode, 8); + }, count: 1); + stream.sendHeaders([Header.ascii('x', 'y')], endStream: false); + stream.incomingMessages.listen( + expectAsync1((msg) { + expect(msg, isA()); + }), + onError: expectAsync1((Object _) {}, count: 0), + onDone: expectAsync0(() { + readyForError.complete(); + }, count: 1), + ); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + var stream = client.makeRequest(headers, endStream: true); + await stream.outgoingMessages.close(); + await readyForError.future; + stream.terminate(); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + transportTest('server-terminates-stream-after-half-close', + (ClientTransportConnection client, + ServerTransportConnection server) async { + var readyForError = Completer(); + + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.sendHeaders([Header.ascii('x', 'y')], endStream: false); + stream.incomingMessages.listen( + expectAsync1((msg) async { + expect(msg, isA()); + await readyForError.future; + stream.terminate(); + }), + onError: expectAsync1((Object _) {}, count: 0), + onDone: expectAsync0(() {}, count: 1), + ); + } + await server.finish(); + } + + Future clientFun() async { + var headers = [Header.ascii('a', 'b')]; + var stream = client.makeRequest(headers, endStream: false); + stream.onTerminated = expectAsync1((Object? errorCode) { + expect(errorCode, 8); + }, count: 1); + readyForError.complete(); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + }); + + transportTest('idle-handler', (ClientTransportConnection client, + ServerTransportConnection server) async { + Future serverFun() async { + var activeCount = 0; + var idleCount = 0; + server.onActiveStateChanged = expectAsync1((active) { + if (active) { + activeCount++; + } else { + idleCount++; + } + }, count: 6); + await for (final stream in server.incomingStreams) { + stream.sendHeaders([]); + unawaited(stream.incomingMessages + .toList() + .then((_) => stream.outgoingMessages.close())); + } + await server.finish(); + expect(activeCount, 3); + expect(idleCount, 3); + } + + Future clientFun() async { + var activeCount = 0; + var idleCount = 0; + client.onActiveStateChanged = expectAsync1((active) { + if (active) { + activeCount++; + } else { + idleCount++; + } + }, count: 6); + final streams = List.generate( + 5, (_) => client.makeRequest([])); + await Future.wait(streams.map((s) => s.outgoingMessages.close())); + await Future.wait(streams.map((s) => s.incomingMessages.toList())); + // This extra await is needed to allow the idle handler to run before + // verifying the idleCount, because the stream cleanup runs + // asynchronously after the stream is closed. + await Future.value(); + expect(activeCount, 1); + expect(idleCount, 1); + + var stream = client.makeRequest([]); + await stream.outgoingMessages.close(); + await stream.incomingMessages.toList(); + await Future.value(); + + stream = client.makeRequest([]); + await stream.outgoingMessages.close(); + await stream.incomingMessages.toList(); + await Future.value(); + + await client.finish(); + expect(activeCount, 3); + expect(idleCount, 3); + } + + await Future.wait([clientFun(), serverFun()]); + }); + + group('flow-control', () { + const kChunkSize = 1024; + const kNumberOfMessages = 1000; + final headers = [Header.ascii('a', 'b')]; + + Future testWindowSize( + ClientTransportConnection client, + ServerTransportConnection server, + int expectedStreamFlowcontrolWindow) async { + expect(expectedStreamFlowcontrolWindow, + lessThan(kChunkSize * kNumberOfMessages)); + + var serverSentBytes = 0; + var flowcontrolWindowFull = Completer(); + + Future serverFun() async { + await for (ServerTransportStream stream in server.incomingStreams) { + stream.sendHeaders([Header.ascii('x', 'y')]); + + var messageNr = 0; + var controller = StreamController(); + void addData() { + if (!controller.isPaused) { + if (messageNr < kNumberOfMessages) { + var messageBytes = Uint8List(kChunkSize); + for (var j = 0; j < messageBytes.length; j++) { + messageBytes[j] = (messageNr + j) % 256; + } + controller.add(DataStreamMessage(messageBytes)); + + messageNr++; + serverSentBytes += messageBytes.length; + + Timer.run(addData); + } else { + if (!controller.isClosed) controller.close(); + } + } + } + + controller + ..onListen = addData + ..onPause = expectAsync0(() { + // Assert that we're now at the place (since the granularity + // of adding is [kChunkSize], it could be that we added + // [kChunkSize - 1] bytes more than allowed, before getting + // the pause event). + expect(serverSentBytes - kChunkSize + 1, + lessThan(expectedStreamFlowcontrolWindow)); + flowcontrolWindowFull.complete(); + }) + ..onResume = addData + ..onCancel = () {}; + + await stream.outgoingMessages.addStream(controller.stream); + await stream.outgoingMessages.close(); + await stream.incomingMessages.toList(); + } + await server.finish(); + } + + Future clientFun() async { + var stream = client.makeRequest(headers, endStream: true); + + var gotHeadersFrame = false; + var byteNr = 0; + + var sub = stream.incomingMessages.listen((message) { + if (!gotHeadersFrame) { + expect(message, isA()); + gotHeadersFrame = true; + } else { + expect(message, isA()); + var dataMessage = message as DataStreamMessage; + + // We're just testing the first byte, to make the test faster. + expect(dataMessage.bytes[0], + ((byteNr ~/ kChunkSize) + (byteNr % kChunkSize)) % 256); + + byteNr += dataMessage.bytes.length; + } + }); + + // We pause immediately, making the server fill the stream flowcontrol + // window. + sub.pause(); + + await flowcontrolWindowFull.future; + sub.resume(); + await client.finish(); + } + + await Future.wait([serverFun(), clientFun()]); + } + + transportTest('fast-sender-receiver-paused--default-window-size', + (ClientTransportConnection client, + ServerTransportConnection server) async { + await testWindowSize(client, server, Window().size); + }); + + transportTest('fast-sender-receiver-paused--10kb-window-size', + (ClientTransportConnection client, + ServerTransportConnection server) async { + await testWindowSize(client, server, 8096); + }, clientSettings: const ClientSettings(streamWindowSize: 8096)); + }); + }); +} + +void transportTest( + String name, + Future Function(ClientTransportConnection, ServerTransportConnection) + func, + {ClientSettings? clientSettings, + ServerSettings? serverSettings}) { + return test(name, () { + var bidirectional = BidirectionalConnection(); + bidirectional.clientSettings = clientSettings; + bidirectional.serverSettings = serverSettings; + var client = bidirectional.clientConnection; + var server = bidirectional.serverConnection; + return func(client, server); + }); +} + +class BidirectionalConnection { + ClientSettings? clientSettings; + ServerSettings? serverSettings; + + final StreamController> writeA = StreamController(); + final StreamController> writeB = StreamController(); + + Stream> get readA => writeA.stream; + + Stream> get readB => writeB.stream; + + ClientTransportConnection get clientConnection => + ClientTransportConnection.viaStreams(readA, writeB.sink, + settings: clientSettings); + + ServerTransportConnection get serverConnection => + ServerTransportConnection.viaStreams(readB, writeA.sink, + settings: serverSettings); +}