feat(http2): add a pooled, multiplexed HTTP/2 http.Client (#1956)
diff --git a/pkgs/http2/CHANGELOG.md b/pkgs/http2/CHANGELOG.md
index be4b0e4..e06329b 100644
--- a/pkgs/http2/CHANGELOG.md
+++ b/pkgs/http2/CHANGELOG.md
@@ -1,8 +1,14 @@
-## 3.0.1-wip
+## 3.1.0-wip
- Gracefully handle receiving headers on a stream that the client has canceled. (#1799)
- Treat incoming server push streams as connection protocol error when pushes are disabled (SETTINGS_ENABLE_PUSH=0).
- Enforce the locally advertised `SETTINGS_MAX_CONCURRENT_STREAMS` limit on incoming remote streams.
+- Add `Http2Client` (`package:http2/client.dart`), a pooled, multiplexed
+ `package:http` `Client` backed by HTTP/2 connections.
+- Add `ClientTransportConnection.peerMaxConcurrentStreams`, exposing the peer's
+ most recently advertised `SETTINGS_MAX_CONCURRENT_STREAMS`. Note this is a
+ new member on an implementable class, so any existing
+ `implements ClientTransportConnection` will need updating.
## 3.0.0
diff --git a/pkgs/http2/README.md b/pkgs/http2/README.md
index edb75c9..5777f8e 100644
--- a/pkgs/http2/README.md
+++ b/pkgs/http2/README.md
@@ -53,3 +53,29 @@
An example with better error handling is available [here][example].
See the [API docs][api] for more details.
+
+## Pooled `http.Client`
+
+`package:http2/client.dart` provides `Http2Client`, a `package:http`
+`Client` that pools and multiplexes requests over shared HTTP/2 connections
+instead of opening one connection per request. This is useful for workloads
+that send many concurrent requests to the same host or hosts, where
+`dart:io`'s `HttpClient` (HTTP/1.1 only) would otherwise open a new TCP+TLS
+connection per request.
+
+```dart
+import 'package:http2/client.dart';
+
+Future<void> main() async {
+ final client = Http2Client();
+ final response = await client.get(Uri.parse('https://example.com/'));
+ print(response.body);
+ client.close();
+}
+```
+
+A connection is dialed per `host:port` as needed, so a single `Http2Client`
+is safe to reuse across requests to different hosts. Note that it speaks only
+HTTP/2 and does not fall back to HTTP/1.1: a server that does not negotiate
+`h2` is treated as an error. See the example
+[here](example/main.dart).
diff --git a/pkgs/http2/example/main.dart b/pkgs/http2/example/main.dart
new file mode 100644
index 0000000..7d58a89
--- /dev/null
+++ b/pkgs/http2/example/main.dart
@@ -0,0 +1,30 @@
+// Copyright (c) 2026, 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' as convert;
+
+import 'package:http2/client.dart';
+
+void main(List<String> arguments) async {
+ // This example uses the pub.dev API to fetch details about
+ // package:http2.
+ // https://pub.dev/help/api
+ final client = Http2Client();
+ final url = Uri.https('pub.dev', '/api/packages/http2/score');
+
+ // Await the http get response, then decode the json-formatted response.
+ final response = await client.get(url);
+ if (response.statusCode == 200) {
+ var jsonResponse =
+ convert.jsonDecode(response.body) as Map<String, dynamic>;
+ var likes = jsonResponse['likeCount'];
+ var downloads = jsonResponse['downloadCount30Days'];
+ print('Information about package:http2:');
+ print('- Likes: $likes');
+ print('- 30-day downloads: $downloads');
+ } else {
+ print('Request failed with status: ${response.statusCode}.');
+ }
+ client.close();
+}
diff --git a/pkgs/http2/lib/client.dart b/pkgs/http2/lib/client.dart
new file mode 100644
index 0000000..90759b1
--- /dev/null
+++ b/pkgs/http2/lib/client.dart
@@ -0,0 +1,13 @@
+// Copyright (c) 2026, 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.
+
+/// A pooled, multiplexed `package:http` `Client` backed by HTTP/2
+/// connections.
+///
+/// See [Http2Client].
+library;
+
+import 'src/http2_client.dart' show Http2Client;
+
+export 'src/http2_client.dart' show Http2Client;
diff --git a/pkgs/http2/lib/src/client_pool.dart b/pkgs/http2/lib/src/client_pool.dart
new file mode 100644
index 0000000..359ee75
--- /dev/null
+++ b/pkgs/http2/lib/src/client_pool.dart
@@ -0,0 +1,298 @@
+// Copyright (c) 2026, 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.
+
+/// A pool of HTTP/2 connections, each carrying several streams at once.
+///
+/// Create one by saying how a connection is dialed and how many streams it may
+/// carry:
+///
+/// ```dart
+/// final pool = ClientPool(
+/// () => dial(host, port),
+/// maxConcurrentStreams: 100,
+/// );
+/// ```
+///
+/// Use [ClientPool.run] when the connection is free again as soon as the
+/// returned future completes:
+///
+/// ```dart
+/// final result = await pool.run((connection) => send(connection, request));
+/// ```
+///
+/// Use [ClientPool.acquire] when the connection is still in use after that
+/// future completes - an HTTP/2 response, for example, is returned as soon as
+/// its headers arrive but keeps its stream open while the body is delivered.
+/// The caller then owns the slot until it releases the lease:
+///
+/// ```dart
+/// final lease = await pool.acquire();
+/// try {
+/// return startStreaming(lease.connection, onDone: lease.release);
+/// } catch (_) {
+/// lease.release();
+/// rethrow;
+/// }
+/// ```
+///
+/// Finally, [ClientPool.terminate] waits for in-flight streams and then closes
+/// every connection.
+library;
+
+import 'dart:async';
+import 'dart:math';
+
+import 'package:collection/collection.dart';
+import 'package:meta/meta.dart';
+
+import 'connection.dart';
+
+/// One pooled connection, and the bookkeeping the pool keeps for it.
+class _PooledConnection {
+ _PooledConnection(this.creation);
+
+ /// Completes with the connection once it has been dialed.
+ final Future<ClientConnection> creation;
+
+ /// The resolved value of [creation], once available.
+ ///
+ /// Kept because scheduling decisions are synchronous by design, so they
+ /// need to consult the connection itself without waiting on [creation].
+ ClientConnection? value;
+
+ /// How many streams are running on this connection right now.
+ int inFlightCount = 0;
+
+ /// Whether the pool should stop routing new work to this connection.
+ ///
+ /// Set when dialing it failed, and also by [PoolLease.markFailed] when a
+ /// stream on it failed - either way it is no longer trusted, and it is
+ /// closed once the streams it is still carrying finish.
+ bool createFailed = false;
+}
+
+/// A claim on one stream slot of a pooled connection.
+///
+/// Held from [ClientPool.acquire] until [release], which lets a slot outlive
+/// the future that produced it - an HTTP/2 response, for instance, is returned
+/// as soon as its headers arrive but keeps its stream open until the body ends.
+class PoolLease {
+ PoolLease._(this._pool, this._pooled, this.connection);
+
+ final ClientPool _pool;
+ final _PooledConnection _pooled;
+
+ /// The connection this slot was claimed on.
+ final ClientConnection connection;
+
+ var _released = false;
+
+ /// Stops the pool routing new work to this connection.
+ void markFailed() => _pooled.createFailed = true;
+
+ /// Gives the slot back. Idempotent, so it is safe to call from several
+ /// terminal paths that may race.
+ void release() {
+ if (_released) return;
+ _released = true;
+ _pool._freeSlot(_pooled);
+ }
+}
+
+/// A pool of HTTP/2 connections.
+///
+/// Packs streams onto the most-loaded connection under its capacity (rather
+/// than spreading them evenly), dials another once existing ones are full,
+/// stops routing new work to a connection once a stream on it throws, and
+/// closes idle connections past [maxIdleConnections].
+///
+/// Capacity is [maxConcurrentStreams], lowered to whatever limit a server
+/// advertises for its own connection.
+class ClientPool {
+ ClientPool(
+ Future<ClientConnection> Function() dial, {
+ required this.maxConcurrentStreams,
+ this.maxIdleConnections = 1,
+ }) : _dial = dial;
+
+ final Future<ClientConnection> Function() _dial;
+
+ final int maxConcurrentStreams;
+ final int maxIdleConnections;
+
+ final _connections = <_PooledConnection>[];
+ final _pendingCloses = <Future<void>>{};
+ var _terminated = false;
+ Completer<void>? _drained;
+ Future<void>? _termination;
+
+ /// The number of connections currently pooled.
+ int get size => _connections.length;
+
+ /// The number of in-flight streams across every connection. For testing.
+ @visibleForTesting
+ int get opCount => _connections.map((c) => c.inFlightCount).sum;
+
+ /// Claims a slot on an available (or newly dialed) connection.
+ ///
+ /// The caller owns the returned lease and must [PoolLease.release] it on
+ /// every path, errors included, or the slot leaks and [terminate] never
+ /// drains. Prefer [run] unless the slot has to outlive the future that
+ /// produced whatever the caller is returning.
+ Future<PoolLease> acquire() async {
+ if (_terminated) {
+ throw StateError('This pool has already been terminated.');
+ }
+
+ while (true) {
+ final pooled = _select();
+ pooled.inFlightCount++;
+ final PoolLease lease;
+ try {
+ lease = PoolLease._(
+ this,
+ pooled,
+ pooled.value ??= await pooled.creation,
+ );
+ } catch (_) {
+ pooled.createFailed = true;
+ _freeSlot(pooled);
+ rethrow;
+ }
+
+ if (pooled.inFlightCount <= _capacityOf(pooled)) return lease;
+ lease.release();
+ }
+ }
+
+ /// Returns one slot to [pooled], closing the connection if it has gone idle.
+ void _freeSlot(_PooledConnection pooled) {
+ pooled.inFlightCount--;
+ if (_terminated) {
+ _maybeCompleteDrain();
+ } else {
+ _closeIfIdle(pooled);
+ }
+ }
+
+ /// Runs [operation] on an available (or newly dialed) connection.
+ Future<R> run<R>(
+ Future<R> Function(ClientConnection connection) operation,
+ ) async {
+ final lease = await acquire();
+ try {
+ return await operation(lease.connection);
+ } catch (_) {
+ lease.markFailed();
+ rethrow;
+ } finally {
+ lease.release();
+ }
+ }
+
+ // Synchronous (no `await`), so concurrent calls can't race each other
+ // into both dialing a connection before either sees the other's.
+ _PooledConnection _select() {
+ _PooledConnection? selected;
+ for (final pooled in _connections) {
+ if (pooled.createFailed) continue;
+ // Pick the available connection with the most inflight requests. This
+ // makes it more likely that fewer active connections need to be
+ // maintained.
+ if (pooled.inFlightCount < _capacityOf(pooled) &&
+ (selected == null || pooled.inFlightCount > selected.inFlightCount)) {
+ selected = pooled;
+ }
+ }
+ if (selected != null) return selected;
+
+ final pooled = _PooledConnection(_dial());
+ _connections.add(pooled);
+ return pooled;
+ }
+
+ /// How many streams [pooled] may carry at once.
+ ///
+ /// A connection that hasn't been dialed yet, or whose server advertises no
+ /// limit of its own, is held to [maxConcurrentStreams].
+ int _capacityOf(_PooledConnection pooled) {
+ final connection = pooled.value;
+ if (connection == null) return maxConcurrentStreams;
+ final limit = connection.peerMaxConcurrentStreams;
+ // Floored at one: a server advertising zero would otherwise make the
+ // connection unusable, and acquire()'s confirm loop would spin.
+ return limit == null
+ ? maxConcurrentStreams
+ : max(1, min(maxConcurrentStreams, limit));
+ }
+
+ void _closeIfIdle(_PooledConnection pooled) {
+ if (pooled.inFlightCount > 0) return;
+ if (!pooled.createFailed && !_hasExcessIdleCapacity(pooled)) return;
+
+ _connections.remove(pooled);
+ _startClose(pooled);
+ }
+
+ /// Starts closing [pooled] without waiting for it, so that a stream is never
+ /// held up by its connection's teardown - `finish()` itself waits on every
+ /// other stream still running on that connection.
+ ///
+ /// Tracked in [_pendingCloses] so [terminate] can still promise that every
+ /// connection has actually been closed by the time it completes.
+ void _startClose(_PooledConnection pooled) {
+ final connection = pooled.value;
+ final done =
+ connection != null
+ ? connection.finish()
+ : pooled.creation.then((c) => c.finish());
+ final tracked = done.then<void>((_) {}).catchError((Object _) {});
+ _pendingCloses.add(tracked);
+ unawaited(tracked.whenComplete(() => _pendingCloses.remove(tracked)));
+ }
+
+ bool _hasExcessIdleCapacity(_PooledConnection pooled) {
+ final idleCapacity =
+ _connections
+ .map(
+ (other) =>
+ other.createFailed
+ ? 0
+ : _capacityOf(other) - other.inFlightCount,
+ )
+ .sum;
+ return idleCapacity > maxIdleConnections * _capacityOf(pooled);
+ }
+
+ void _maybeCompleteDrain() {
+ if (_drained case final drained?
+ when !drained.isCompleted && opCount == 0) {
+ drained.complete();
+ }
+ }
+
+ /// Waits for in-flight streams to finish, then closes every connection in
+ /// the pool. No further streams can be opened afterward.
+ ///
+ /// Idempotent: concurrent and repeated calls all observe the same shutdown.
+ Future<void> terminate() => _termination ??= _terminate();
+
+ Future<void> _terminate() async {
+ _terminated = true;
+
+ if (opCount > 0) {
+ _drained = Completer<void>();
+ await _drained!.future;
+ }
+
+ // Snapshotted and cleared before any `await`, so nothing is iterating
+ // _connections across a suspension point.
+ final connections = _connections.toList();
+ _connections.clear();
+ for (final pooled in connections) {
+ _startClose(pooled);
+ }
+ await Future.wait(_pendingCloses.toList());
+ }
+}
diff --git a/pkgs/http2/lib/src/connection.dart b/pkgs/http2/lib/src/connection.dart
index 6a9f3fc..eb866cb 100644
--- a/pkgs/http2/lib/src/connection.dart
+++ b/pkgs/http2/lib/src/connection.dart
@@ -514,6 +514,10 @@
!_state.isFinishing && !_state.isTerminated && _streams.canOpenStream;
@override
+ int? get peerMaxConcurrentStreams =>
+ _settingsHandler.peerSettings.maxConcurrentStreams;
+
+ @override
ClientTransportStream makeRequest(
List<Header> headers, {
bool endStream = false,
diff --git a/pkgs/http2/lib/src/http2_client.dart b/pkgs/http2/lib/src/http2_client.dart
new file mode 100644
index 0000000..1f234bb
--- /dev/null
+++ b/pkgs/http2/lib/src/http2_client.dart
@@ -0,0 +1,435 @@
+// Copyright (c) 2026, 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 'dart:typed_data';
+
+import 'package:collection/collection.dart';
+import 'package:http/http.dart';
+import 'package:meta/meta.dart';
+import 'package:pool/pool.dart';
+
+import '../transport.dart';
+import 'client_pool.dart';
+import 'connection.dart';
+
+/// A pooled, multiplexed `http.Client` backed by HTTP/2 connections.
+///
+/// Every request is sent as its own HTTP/2 stream on a connection shared with
+/// other requests to the same `host:port`. Once a connection is carrying as
+/// many concurrent streams as it may - the lower of
+/// [maxStreamsPerConnection] and the server's own advertised limit - another
+/// connection is dialed rather than queuing behind the existing one. A
+/// connection dialed for one host is never reused for another.
+///
+/// This client speaks only HTTP/2, and does not fall back to HTTP/1.1: a
+/// server that does not negotiate `h2` over ALPN is treated as an error
+/// rather than retried over HTTP/1.1.
+///
+/// `onBadCertificate` is forwarded as-is to `SecureSocket.connect`: returning
+/// `true` accepts a certificate that failed normal verification (expired,
+/// self-signed, wrong host, ...). It exists for tests and trusted private
+/// networks - do not use it to accept arbitrary certificates in production.
+@experimental
+class Http2Client extends BaseClient {
+ Http2Client({
+ this.maxStreamsPerConnection = 100,
+ this.maxIdleConnections = 1,
+ this.settingsTimeout = const Duration(seconds: 10),
+ int maxConcurrentHandshakes = 50,
+ SecurityContext? context,
+ bool Function(X509Certificate certificate)? onBadCertificate,
+ }) : _context = context,
+ _onBadCertificate = onBadCertificate,
+ _handshakeGate = Pool(maxConcurrentHandshakes);
+
+ /// The maximum number of concurrent HTTP/2 streams (i.e. requests) to
+ /// multiplex onto a single connection before dialing another.
+ ///
+ /// An upper bound only: if a server says it accepts fewer concurrent
+ /// streams than this, that smaller number is used for its connections.
+ final int maxStreamsPerConnection;
+
+ /// The maximum number of idle connections to keep per host.
+ final int maxIdleConnections;
+
+ /// How long to wait for a freshly dialed connection's peer to send its
+ /// mandatory initial SETTINGS frame (RFC 7540 3.5).
+ ///
+ /// Until it arrives the peer's stream limit is unknown, so the connection
+ /// can't be safely multiplexed onto; a peer that never sends one is
+ /// abandoned rather than waited on forever.
+ final Duration settingsTimeout;
+
+ final SecurityContext? _context;
+
+ // Forwarded to `SecureSocket.connect` as-is: returning `true` accepts a
+ // certificate that failed normal verification (expired, self-signed,
+ // wrong host, ...). Intended for tests and trusted private networks -
+ // never use this to accept arbitrary certificates in production.
+ final bool Function(X509Certificate certificate)? _onBadCertificate;
+
+ // Caps concurrent in-flight TCP+TLS handshakes across every host,
+ // independent of how many connections any one host's pool ends up
+ // needing.
+ final Pool _handshakeGate;
+
+ final _pools = <String, ClientPool>{};
+ var _closed = false;
+ Future<void>? _shutdown;
+
+ // Synchronous (no `await`), so concurrent requests to a new host:port
+ // can't race each other into creating two pools for the same key.
+ ClientPool _poolFor(Uri url) {
+ final key = '${url.host}:${url.port}';
+ return _pools.putIfAbsent(
+ key,
+ () => ClientPool(
+ () => _handshakeGate.withResource(() => _dial(url.host, url.port)),
+ maxConcurrentStreams: maxStreamsPerConnection,
+ maxIdleConnections: maxIdleConnections,
+ ),
+ );
+ }
+
+ Future<ClientConnection> _dial(String host, int port) async {
+ final socket = await SecureSocket.connect(
+ host,
+ port,
+ context: _context,
+ onBadCertificate: _onBadCertificate,
+ supportedProtocols: ['h2'],
+ );
+ if (socket.selectedProtocol != 'h2') {
+ socket.destroy();
+ throw StateError(
+ 'Server did not negotiate HTTP/2 (got ${socket.selectedProtocol})',
+ );
+ }
+
+ // A connection can't be multiplexed onto until the peer has said how many
+ // concurrent streams it allows, which it does in a SETTINGS frame sent
+ // right after connecting - so wait for that before handing it to the pool.
+ //
+ // `onInitialPeerSettingsReceived` only ever completes successfully, never
+ // with an error, so awaiting it alone would hang forever against a peer
+ // that connects and then goes quiet. Watch the socket for the connection
+ // dying, and cap the wait with `settingsTimeout`.
+ final died = Completer<void>();
+ final incoming = socket.transform(
+ StreamTransformer<Uint8List, List<int>>.fromHandlers(
+ handleDone: (sink) {
+ if (!died.isCompleted) died.complete();
+ sink.close();
+ },
+ handleError: (error, stackTrace, sink) {
+ if (!died.isCompleted) died.completeError(error, stackTrace);
+ sink.addError(error, stackTrace);
+ },
+ ),
+ );
+ unawaited(died.future.catchError((Object _) {}));
+
+ final transport = ClientConnection(
+ incoming,
+ socket,
+ const ClientSettings(),
+ );
+ try {
+ await Future.any([
+ transport.onInitialPeerSettingsReceived,
+ died.future.then<void>(
+ (_) =>
+ throw ClientException(
+ 'The connection closed before the peer sent its initial '
+ 'SETTINGS frame.',
+ ),
+ ),
+ ]).timeout(settingsTimeout);
+ } catch (_) {
+ // Not awaited: terminating writes a GOAWAY frame, and if the peer has
+ // already gone that write may never complete - which would hang the
+ // dial and defeat the timeout above. Cancelling the frame reader,
+ // which closes the socket, happens synchronously inside terminate().
+ unawaited(transport.terminate().catchError((Object _) => null));
+ rethrow;
+ }
+ return transport;
+ }
+
+ /// Sends [request] as a single HTTP/2 stream on [lease]'s connection,
+ /// translating between `BaseRequest`/`StreamedResponse` and http2's frames.
+ ///
+ /// Returns once the response headers arrive, but takes ownership of [lease]:
+ /// the stream stays open while the body is delivered, so the slot is only
+ /// released once that stream reaches a terminal state.
+ Future<StreamedResponse> _sendOverHttp2(
+ PoolLease lease,
+ BaseRequest request,
+ List<int> bodyBytes,
+ ) async {
+ final transport = lease.connection;
+ if (!transport.isOpen) throw const _ConnectionClosedByPeer();
+
+ final rawPath = request.url.path.isEmpty ? '/' : request.url.path;
+ final path =
+ request.url.hasQuery ? '$rawPath?${request.url.query}' : rawPath;
+
+ final stream = transport.makeRequest([
+ Header.ascii(':method', request.method),
+ Header.ascii(':scheme', 'https'),
+ Header.ascii(':authority', _authorityOf(request.url)),
+ Header.ascii(':path', path),
+ for (final entry in request.headers.entries)
+ if (!_connectionSpecificHeaders.contains(entry.key.toLowerCase()))
+ Header.ascii(entry.key.toLowerCase(), entry.value),
+ ], endStream: bodyBytes.isEmpty);
+
+ if (bodyBytes.isNotEmpty) stream.sendData(bodyBytes, endStream: true);
+
+ final statusCompleter = Completer<int>();
+ late final StreamSubscription<StreamMessage> subscription;
+ final bodyController = StreamController<List<int>>(
+ onCancel: () {
+ lease.release();
+ stream.terminate();
+ return subscription.cancel();
+ },
+ );
+ final responseHeaders = <String, String>{};
+
+ subscription = stream.incomingMessages.listen(
+ (message) {
+ if (message is HeadersStreamMessage) {
+ for (final header in message.headers) {
+ final name = latin1.decode(header.name);
+ final value = latin1.decode(header.value);
+ if (name == ':status') {
+ final status = int.tryParse(value);
+ if (status == null) {
+ if (!statusCompleter.isCompleted) {
+ statusCompleter.completeError(
+ ClientException(
+ 'Invalid HTTP/2 ":status" value "$value"',
+ request.url,
+ ),
+ );
+ }
+ } else if (status >= 200 && !statusCompleter.isCompleted) {
+ statusCompleter.complete(status);
+ }
+ } else {
+ responseHeaders.update(
+ name,
+ (existing) => '$existing, $value',
+ ifAbsent: () => value,
+ );
+ }
+ }
+ } else if (message is DataStreamMessage) {
+ bodyController.add(message.bytes);
+ }
+ },
+ onDone: () {
+ if (!statusCompleter.isCompleted) {
+ statusCompleter.completeError(
+ ClientException(
+ 'Stream closed before a response status was received',
+ request.url,
+ ),
+ );
+ }
+ if (!bodyController.isClosed) {
+ bodyController.close();
+ }
+ lease.release();
+ },
+ onError: (Object error, StackTrace stackTrace) {
+ final failure =
+ error is ClientException
+ ? error
+ : ClientException('$error', request.url);
+ if (!statusCompleter.isCompleted) {
+ statusCompleter.completeError(failure, stackTrace);
+ }
+ bodyController.addError(failure, stackTrace);
+ if (!bodyController.isClosed) {
+ bodyController.close();
+ }
+ lease.release();
+ },
+ cancelOnError: true,
+ );
+
+ final statusCode = await statusCompleter.future;
+ return StreamedResponse(
+ bodyController.stream,
+ statusCode,
+ contentLength: int.tryParse(responseHeaders['content-length'] ?? ''),
+ headers: Map.unmodifiable(responseHeaders),
+ reasonPhrase: _reasonPhrases[statusCode],
+ request: request,
+ );
+ }
+
+ /// The number of connections currently pooled, across every host.
+ int get connectionCount => _pools.values.map((pool) => pool.size).sum;
+
+ @override
+ Future<StreamedResponse> send(BaseRequest request) async {
+ if (_closed) {
+ throw ClientException(
+ 'HTTP request failed. Client is already closed.',
+ request.url,
+ );
+ }
+ if (request.url.scheme != 'https') {
+ throw ClientException(
+ 'Http2Client only supports https (got "${request.url.scheme}").',
+ request.url,
+ );
+ }
+
+ List<int>? bodyBytes;
+
+ Future<StreamedResponse> attempt() async {
+ final lease = await _poolFor(request.url).acquire();
+ try {
+ bodyBytes ??= await request.finalize().toBytes();
+ } catch (_) {
+ lease.release();
+ rethrow;
+ }
+ try {
+ return await _sendOverHttp2(lease, request, bodyBytes!);
+ } catch (_) {
+ lease.markFailed();
+ lease.release();
+ rethrow;
+ }
+ }
+
+ return attempt()
+ .catchError(
+ (Object _) => attempt(),
+ test: (error) => error is _ConnectionClosedByPeer,
+ )
+ .catchError(
+ (Object error, StackTrace stackTrace) => Error.throwWithStackTrace(
+ ClientException('$error', request.url),
+ stackTrace,
+ ),
+ test: (error) => error is! ClientException,
+ );
+ }
+
+ /// Rejects further requests, then waits for the in-flight ones to finish
+ /// before closing every connection.
+ ///
+ /// A request counts as in-flight until its response body ends or is
+ /// cancelled, so a caller holding a response it never reads will keep a
+ /// connection open. Shutdown runs in the background, so this never blocks
+ /// on that.
+ @override
+ void close() {
+ _closed = true;
+ _shutdown ??= _closeAll();
+ }
+
+ Future<void> _closeAll() async {
+ final pools = _pools.values.toList();
+ _pools.clear();
+ await Future.wait(pools.map((pool) => pool.terminate()));
+ }
+
+ /// Completes once [close] has finished shutting every connection down.
+ @visibleForTesting
+ Future<void> get closed => _shutdown ?? Future<void>.value();
+}
+
+/// Thrown by [Http2Client._sendOverHttp2] when a pooled connection turns
+/// out to have already been closed by the peer (e.g. a graceful `GOAWAY`)
+/// before any bytes were written for this request. [Http2Client.send]
+/// catches this and retries once on whatever the pool dials next, having
+/// marked the dead connection failed so it isn't handed out again.
+class _ConnectionClosedByPeer implements Exception {
+ const _ConnectionClosedByPeer();
+
+ @override
+ String toString() =>
+ 'The pooled HTTP/2 connection was closed by the peer before this '
+ 'request could be sent.';
+}
+
+/// HTTP/2 carries no reason phrase (RFC 9113 8.3.2 dropped it as redundant
+/// with the status code), so one is derived from the status instead - the same
+/// approach `package:cupertino_http` takes for NSURLSession.
+const _reasonPhrases = {
+ 100: 'Continue',
+ 101: 'Switching Protocols',
+ 200: 'OK',
+ 201: 'Created',
+ 202: 'Accepted',
+ 203: 'Non-Authoritative Information',
+ 204: 'No Content',
+ 205: 'Reset Content',
+ 206: 'Partial Content',
+ 300: 'Multiple Choices',
+ 301: 'Moved Permanently',
+ 302: 'Found',
+ 303: 'See Other',
+ 304: 'Not Modified',
+ 305: 'Use Proxy',
+ 307: 'Temporary Redirect',
+ 308: 'Permanent Redirect',
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 402: 'Payment Required',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 405: 'Method Not Allowed',
+ 406: 'Not Acceptable',
+ 407: 'Proxy Authentication Required',
+ 408: 'Request Time-out',
+ 409: 'Conflict',
+ 410: 'Gone',
+ 411: 'Length Required',
+ 412: 'Precondition Failed',
+ 413: 'Request Entity Too Large',
+ 414: 'Request-URI Too Long',
+ 415: 'Unsupported Media Type',
+ 416: 'Requested range not satisfiable',
+ 417: 'Expectation Failed',
+ 421: 'Misdirected Request',
+ 422: 'Unprocessable Entity',
+ 426: 'Upgrade Required',
+ 428: 'Precondition Required',
+ 429: 'Too Many Requests',
+ 431: 'Request Header Fields Too Large',
+ 500: 'Internal Server Error',
+ 501: 'Not Implemented',
+ 502: 'Bad Gateway',
+ 503: 'Service Unavailable',
+ 504: 'Gateway Time-out',
+ 505: 'Http Version not supported',
+ 511: 'Network Authentication Required',
+};
+
+/// RFC 9113 8.3.1: ":authority" carries the port unless it is the default for
+/// the scheme - which is always https here, so 443.
+String _authorityOf(Uri url) =>
+ url.port == 443 ? url.host : '${url.host}:${url.port}';
+
+/// Header fields forbidden on an HTTP/2 stream (RFC 7540 8.1.2.2), plus
+/// `host` since `:authority` already carries what it would.
+const _connectionSpecificHeaders = {
+ 'connection',
+ 'keep-alive',
+ 'proxy-connection',
+ 'transfer-encoding',
+ 'upgrade',
+ 'host',
+};
diff --git a/pkgs/http2/lib/transport.dart b/pkgs/http2/lib/transport.dart
index 4584e71..a56f151 100644
--- a/pkgs/http2/lib/transport.dart
+++ b/pkgs/http2/lib/transport.dart
@@ -97,6 +97,14 @@
/// via [makeRequest].
bool get isOpen;
+ /// The maximum number of concurrent streams the peer currently allows, per
+ /// its most recent SETTINGS_MAX_CONCURRENT_STREAMS (RFC 7540 6.5.2), or
+ /// `null` if the peer hasn't advertised a limit.
+ ///
+ /// This is only known once the peer's initial SETTINGS frame has arrived -
+ /// see [onInitialPeerSettingsReceived].
+ int? get peerMaxConcurrentStreams;
+
/// Creates a new outgoing stream.
ClientTransportStream makeRequest(
List<Header> headers, {
diff --git a/pkgs/http2/pubspec.yaml b/pkgs/http2/pubspec.yaml
index e65f0a3..1b99a90 100644
--- a/pkgs/http2/pubspec.yaml
+++ b/pkgs/http2/pubspec.yaml
@@ -1,5 +1,5 @@
name: http2
-version: 3.0.1-wip
+version: 3.1.0-wip
description: A HTTP/2 implementation in Dart.
repository: https://github.com/dart-lang/http/tree/master/pkgs/http2
@@ -11,8 +11,20 @@
environment:
sdk: ^3.7.0
+dependencies:
+ collection: ^1.19.0
+ http: ^1.5.0
+ meta: ^1.15.0
+ pool: ^1.5.0
+
dev_dependencies:
build_runner: ^2.4.15
dart_flutter_team_lints: ^3.5.1
+ http_client_conformance_tests:
+ path: ../http_client_conformance_tests/
mockito: ^5.4.5
test: ^1.25.15
+
+dependency_overrides:
+ http:
+ path: ../http/
diff --git a/pkgs/http2/test/client_conformance_test.dart b/pkgs/http2/test/client_conformance_test.dart
new file mode 100644
index 0000000..0294ffe
--- /dev/null
+++ b/pkgs/http2/test/client_conformance_test.dart
@@ -0,0 +1,283 @@
+// Copyright (c) 2026, 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:http/http.dart' as http;
+import 'package:http/io_client.dart';
+import 'package:http2/src/http2_client.dart';
+import 'package:http2/transport.dart';
+import 'package:http_client_conformance_tests/http_client_conformance_tests.dart';
+import 'package:test/test.dart';
+
+class Http2ProxyServer {
+ final SecureServerSocket _socket;
+ final List<ServerTransportConnection> _connections = [];
+ final IOClient _httpClient = IOClient();
+
+ Http2ProxyServer._(this._socket) {
+ _socket.listen((socket) {
+ final connection = ServerTransportConnection.viaSocket(socket);
+ _connections.add(connection);
+ connection.incomingStreams.listen(_handleStream);
+ });
+ }
+
+ static Future<Http2ProxyServer> start() async {
+ final context =
+ SecurityContext()
+ ..useCertificateChain('test/certificates/server_chain.pem')
+ ..usePrivateKey(
+ 'test/certificates/server_key.pem',
+ password: 'dartdart',
+ )
+ ..setAlpnProtocols(['h2'], true);
+ final socket = await SecureServerSocket.bind('localhost', 0, context);
+ return Http2ProxyServer._(socket);
+ }
+
+ int get port => _socket.port;
+
+ Future<void> _handleStream(ServerTransportStream stream) async {
+ try {
+ final messages = StreamIterator(stream.incomingMessages);
+ if (!await messages.moveNext()) return;
+
+ final headersMsg = messages.current as HeadersStreamMessage;
+ String? method;
+ String? path;
+ int? targetPort;
+ final headers = <String, String>{};
+
+ for (final header in headersMsg.headers) {
+ final name = ascii.decode(header.name);
+ final value = ascii.decode(header.value);
+ if (name == ':method') {
+ method = value;
+ } else if (name == ':path') {
+ path = value;
+ } else if (name == 'x-target-port') {
+ targetPort = int.parse(value);
+ } else if (!name.startsWith(':')) {
+ headers[name] = value;
+ }
+ }
+
+ if (method == null || path == null || targetPort == null) {
+ stream.outgoingMessages.add(
+ HeadersStreamMessage([Header.ascii(':status', '400')]),
+ );
+ await stream.outgoingMessages.close();
+ return;
+ }
+
+ // Collect body
+ final bodyBytes = <int>[];
+ while (await messages.moveNext()) {
+ final msg = messages.current;
+ if (msg is DataStreamMessage) {
+ bodyBytes.addAll(msg.bytes);
+ }
+ }
+
+ // Forward to HTTP/1.1 server
+ final targetUri = Uri.parse('http://localhost:$targetPort$path');
+ final httpRequest = http.Request(method, targetUri);
+ headers.forEach((k, v) {
+ httpRequest.headers[k] = v;
+ });
+ httpRequest.bodyBytes = bodyBytes;
+
+ final httpResponse = await _httpClient.send(httpRequest);
+
+ // Send response headers
+ final responseHeaders = <Header>[
+ Header.ascii(':status', httpResponse.statusCode.toString()),
+ ];
+ httpResponse.headers.forEach((k, v) {
+ responseHeaders.add(Header.ascii(k.toLowerCase(), v));
+ });
+ stream.outgoingMessages.add(HeadersStreamMessage(responseHeaders));
+
+ // Send response body
+ await for (final chunk in httpResponse.stream) {
+ stream.outgoingMessages.add(DataStreamMessage(chunk));
+ }
+ await stream.outgoingMessages.close();
+ } catch (e) {
+ print('Proxy error: $e');
+ stream.terminate();
+ }
+ }
+
+ Future<void> close() async {
+ await _socket.close();
+ for (final conn in _connections) {
+ await conn.terminate();
+ }
+ _httpClient.close();
+ }
+}
+
+class ProxyRequest extends http.BaseRequest implements http.Abortable {
+ // `url` is not redeclared here - BaseRequest's own constructor stores it.
+ ProxyRequest(this._original, Uri url) : super(_original.method, url);
+
+ final http.BaseRequest _original;
+
+ @override
+ Future<void>? get abortTrigger =>
+ _original is http.Abortable ? _original.abortTrigger : null;
+
+ @override
+ Map<String, String> get headers => _original.headers;
+
+ @override
+ int? get contentLength => _original.contentLength;
+ @override
+ set contentLength(int? value) => _original.contentLength = value;
+
+ @override
+ bool get followRedirects => _original.followRedirects;
+ @override
+ set followRedirects(bool value) => _original.followRedirects = value;
+
+ @override
+ int get maxRedirects => _original.maxRedirects;
+ @override
+ set maxRedirects(int value) => _original.maxRedirects = value;
+
+ @override
+ bool get persistentConnection => _original.persistentConnection;
+ @override
+ set persistentConnection(bool value) =>
+ _original.persistentConnection = value;
+
+ @override
+ http.ByteStream finalize() {
+ super.finalize();
+ return _original.finalize();
+ }
+}
+
+class ConformanceProxyClient extends http.BaseClient {
+ final Http2Client _inner;
+ final int _proxyPort;
+
+ ConformanceProxyClient(this._inner, this._proxyPort);
+
+ @override
+ Future<http.StreamedResponse> send(http.BaseRequest request) async {
+ final targetPort = request.url.port;
+ final proxyUrl = request.url.replace(
+ scheme: 'https',
+ host: 'localhost',
+ port: _proxyPort,
+ );
+
+ request.headers['x-target-port'] = targetPort.toString();
+ final proxyRequest = ProxyRequest(request, proxyUrl);
+
+ try {
+ final response = await _inner.send(proxyRequest);
+ return http.StreamedResponse(
+ response.stream,
+ response.statusCode,
+ contentLength: response.contentLength,
+ headers: response.headers,
+ isRedirect: response.isRedirect,
+ persistentConnection: response.persistentConnection,
+ reasonPhrase: response.reasonPhrase,
+ request: request,
+ );
+ } on http.ClientException catch (e) {
+ if (e is http.RequestAbortedException) {
+ throw http.RequestAbortedException(request.url);
+ }
+ throw http.ClientException(e.message, request.url);
+ }
+ }
+
+ @override
+ void close() {
+ _inner.close();
+ }
+}
+
+/// [Http2Client] only supports HTTP/2 over TLS (HTTPS). However, the standard
+/// servers started by http_client_conformance_tests only support unencrypted
+/// HTTP/1.1.
+/// To bridge this protocol gap, we run a local HTTP/2 proxy server
+/// ([Http2ProxyServer]) in-process. [ConformanceProxyClient] wraps
+/// [Http2Client] and rewrites the destination URI of all outgoing requests to
+/// point to the local proxy server, attaching a custom `x-target-port` header
+/// to specify the target HTTP/1.1 server port. The proxy server then forwards
+/// the request over HTTP/1.1 and returns the response to [Http2Client] over
+/// HTTP/2.
+void main() {
+ late final Http2ProxyServer proxy;
+
+ setUpAll(() async {
+ proxy = await Http2ProxyServer.start();
+ });
+
+ tearDownAll(() async {
+ await proxy.close();
+ });
+
+ ConformanceProxyClient clientFactory() => ConformanceProxyClient(
+ Http2Client(onBadCertificate: (_) => true),
+ proxy.port,
+ );
+
+ testRequestBody(clientFactory);
+
+ // TODO: Implement request body streaming support in Http2Client.
+ // Currently Http2Client reads the entire request body into memory before
+ // sending.
+ testRequestBodyStreamed(clientFactory, canStreamRequestBody: false);
+
+ testResponseBody(clientFactory);
+ // TODO: Re-enable once request abort support is implemented in Http2Client.
+ // testResponseBodyStreamed(clientFactory);
+ testRequestHeaders(clientFactory);
+ testRequestMethods(clientFactory, preservesMethodCase: false);
+
+ testResponseHeaders(
+ clientFactory,
+ // HTTP/2 explicitly forbids folded headers (RFC 7540 Section 8.1.2.6).
+ supportsFoldedHeaders: false,
+ // HTTP/2 does not allow NUL characters inside header names or values.
+ correctlyHandlesNullHeaderValues: false,
+ );
+
+ testResponseStatusLine(clientFactory);
+
+ // TODO: Implement redirect-following support in Http2Client.
+ // testRedirect(clientFactory);
+
+ testServerErrors(clientFactory);
+ testCompressedResponseBody(clientFactory);
+ testMultipleClients(clientFactory);
+ testMultipartRequests(clientFactory, supportsMultipartRequest: true);
+ testClose(clientFactory);
+
+ // TODO: Support running client conformance tests in isolates.
+ // Currently we set `canWorkInIsolates` to false because the proxy server uses
+ // `SecureServerSocket`, which cannot be sent across isolates.
+ testIsolate(clientFactory, canWorkInIsolates: false);
+
+ testRequestCookies(clientFactory, canSendCookieHeaders: true);
+ testResponseCookies(clientFactory, canReceiveSetCookieHeaders: true);
+
+ // TODO: Implement request abort support in Http2Client.
+ // testAbort(
+ // clientFactory,
+ // supportsAbort: true,
+ // canStreamRequestBody: false,
+ // canStreamResponseBody: true,
+ // );
+}
diff --git a/pkgs/http2/test/client_pool_test.dart b/pkgs/http2/test/client_pool_test.dart
new file mode 100644
index 0000000..3e5a84a
--- /dev/null
+++ b/pkgs/http2/test/client_pool_test.dart
@@ -0,0 +1,501 @@
+// Copyright (c) 2026, 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 'package:http2/src/client_pool.dart';
+import 'package:http2/src/connection.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'pool_mocks.mocks.dart';
+
+/// Dials [MockClientConnection]s for a pool, and records what it does to them.
+class _Connections {
+ /// What every connection advertises as its own concurrent stream limit.
+ ///
+ /// Read on each call rather than captured, so a test can change a server's
+ /// mind part way through.
+ int? limit;
+
+ /// When set, dialing a connection doesn't finish until this does - which is
+ /// how a test controls the window where a connection exists but its own
+ /// limit isn't knowable yet.
+ Future<void>? dialGate;
+
+ /// When set, closing a connection doesn't finish until this does.
+ Future<void>? closeGate;
+
+ final dialed = <ClientConnection>[];
+
+ /// The indices of the connections that have been closed, in order.
+ final closed = <int>[];
+
+ Future<ClientConnection> dial() async {
+ final index = dialed.length;
+ final connection = MockClientConnection();
+ dialed.add(connection);
+ when(connection.peerMaxConcurrentStreams).thenAnswer((_) => limit);
+ when(connection.finish()).thenAnswer((_) async {
+ closed.add(index);
+ if (closeGate != null) await closeGate;
+ return null;
+ });
+ if (dialGate != null) await dialGate;
+ return connection;
+ }
+
+ /// Where [connection] sits in dial order, so assertions can read as indices.
+ int indexOf(ClientConnection connection) => dialed.indexOf(connection);
+}
+
+ClientPool _pool(
+ _Connections connections, {
+ required int maxConcurrentStreams,
+ int maxIdleConnections = 1,
+}) => ClientPool(
+ connections.dial,
+ maxConcurrentStreams: maxConcurrentStreams,
+ maxIdleConnections: maxIdleConnections,
+);
+
+void main() {
+ group('client-pool-test', () {
+ test('dials-new-connections-as-needed', () {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 2);
+ final completers = List.generate(3, (_) => Completer<void>());
+
+ expect(pool.size, 0);
+ unawaited(pool.run((_) => completers[0].future));
+ unawaited(pool.run((_) => completers[1].future));
+ expect(pool.size, 1);
+ unawaited(pool.run((_) => completers[2].future));
+ expect(pool.size, 2);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ });
+
+ test('reuses-a-connection-with-remaining-capacity', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 2);
+ final completers = List.generate(3, (_) => Completer<void>());
+
+ final first = pool.run((_) => completers[0].future);
+ unawaited(pool.run((_) => completers[1].future));
+ expect(pool.size, 1);
+
+ completers[0].complete();
+ await first;
+
+ unawaited(pool.run((_) => completers[2].future));
+ expect(pool.size, 1);
+
+ completers[1].complete();
+ completers[2].complete();
+ });
+
+ test('packs-load-onto-the-most-loaded-connection', () async {
+ final connections = _Connections();
+ final pool = _pool(connections, maxConcurrentStreams: 2);
+ final completers = List.generate(4, (_) => Completer<void>());
+ final used = <int>[];
+
+ void run(int i) => unawaited(
+ pool.run((c) {
+ used.add(connections.indexOf(c));
+ return completers[i].future;
+ }),
+ );
+
+ run(0);
+ run(1);
+ run(2); // Connection 0 is full - this should dial connection 1.
+ await pumpEventQueue();
+ expect(used, [0, 0, 1]);
+
+ completers[0].complete();
+ await pumpEventQueue();
+
+ run(3); // Connection 0 has a free slot and is the most-loaded option.
+ await pumpEventQueue();
+ expect(used, [0, 0, 1, 0]);
+
+ completers[1].complete();
+ completers[2].complete();
+ completers[3].complete();
+ });
+
+ test('stops-reusing-a-connection-after-a-failure', () async {
+ final connections = _Connections();
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+ final used = <int>[];
+
+ await pool
+ .run((c) {
+ used.add(connections.indexOf(c));
+ return Future<void>.error('boom');
+ })
+ .catchError((_) {});
+
+ await pool.run((c) {
+ used.add(connections.indexOf(c));
+ return Future<void>.value();
+ });
+
+ expect(used, [0, 1]);
+ });
+
+ test('closes-connections-after-success', () async {
+ final pool = _pool(
+ _Connections(),
+ maxConcurrentStreams: 2,
+ maxIdleConnections: 0,
+ );
+ final completers = List.generate(4, (_) => Completer<void>());
+
+ final ops = [
+ pool.run((_) => completers[0].future),
+ pool.run((_) => completers[1].future),
+ pool.run((_) => completers[2].future),
+ pool.run((_) => completers[3].future),
+ ];
+ expect(pool.size, 2);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ await Future.wait(ops);
+
+ expect(pool.size, 0);
+ });
+
+ test('closes-connections-after-an-error', () async {
+ final pool = _pool(
+ _Connections(),
+ maxConcurrentStreams: 2,
+ maxIdleConnections: 0,
+ );
+
+ final ops = List.generate(
+ 4,
+ (_) => pool.run((_) => Future<void>.error('boom')).catchError((_) {}),
+ );
+ await Future.wait(ops);
+
+ expect(pool.size, 0);
+ });
+
+ test('keeps-idle-connections-up-to-max-idle-connections', () async {
+ final pool = _pool(
+ _Connections(),
+ maxConcurrentStreams: 1,
+ maxIdleConnections: 3,
+ );
+ final completers = List.generate(4, (_) => Completer<void>());
+
+ final ops = [
+ pool.run((_) => completers[0].future),
+ pool.run((_) => completers[1].future),
+ pool.run((_) => completers[2].future),
+ pool.run((_) => completers[3].future),
+ ];
+ expect(pool.size, 4);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ await Future.wait(ops);
+
+ expect(pool.size, 3);
+ });
+
+ test('honours-a-servers-own-lower-stream-limit', () async {
+ final connections = _Connections()..limit = 2;
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+ final completers = List.generate(3, (_) => Completer<void>());
+ final used = <int>[];
+
+ void run(int i) => unawaited(
+ pool.run((c) {
+ used.add(connections.indexOf(c));
+ return completers[i].future;
+ }),
+ );
+
+ run(0);
+ await pool.run((_) async {}); // Let connection 0 finish dialing.
+ run(1);
+ run(2); // Connection 0 is at its limit of 2 - this dials another.
+ await pumpEventQueue();
+
+ expect(used, [0, 0, 1]);
+ expect(pool.size, 2);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ });
+
+ test('ignores-a-server-limit-above-max-concurrent-streams', () async {
+ final connections = _Connections()..limit = 1000;
+ final pool = _pool(connections, maxConcurrentStreams: 1);
+ final completers = List.generate(2, (_) => Completer<void>());
+
+ unawaited(pool.run((_) => completers[0].future));
+ await pumpEventQueue();
+ unawaited(pool.run((_) => completers[1].future));
+ await pumpEventQueue();
+
+ expect(pool.size, 2);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ });
+
+ test('re-reads-a-server-limit-that-changes', () async {
+ // Stands in for a server revising SETTINGS_MAX_CONCURRENT_STREAMS.
+ final connections = _Connections()..limit = 2;
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+ final completers = List.generate(3, (_) => Completer<void>());
+ final used = <int>[];
+
+ void run(int i) => unawaited(
+ pool.run((c) {
+ used.add(connections.indexOf(c));
+ return completers[i].future;
+ }),
+ );
+
+ run(0);
+ await pool.run((_) async {}); // Let connection 0 finish dialing.
+ run(1); // Still within the limit of 2, so connection 0 is reused.
+ await pumpEventQueue();
+ expect(used, [0, 0]);
+
+ connections.limit = 1;
+ run(2); // Connection 0 is now over its lowered limit.
+ await pumpEventQueue();
+ expect(used, [0, 0, 1]);
+
+ for (final c in completers) {
+ c.complete();
+ }
+ });
+
+ test('keeps-a-limited-connection-that-is-merely-full', () async {
+ final connections = _Connections()..limit = 1;
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+ final completers = List.generate(2, (_) => Completer<void>());
+
+ final first = pool.run((_) => completers[0].future);
+ await pumpEventQueue();
+ final second = pool.run((_) => completers[1].future);
+ await pumpEventQueue();
+ expect(pool.size, 2);
+
+ completers[0].complete();
+ await first;
+ expect(connections.closed, isEmpty);
+ expect(pool.size, 2);
+
+ completers[1].complete();
+ await second;
+
+ expect(connections.closed, hasLength(1));
+ expect(pool.size, 1);
+ });
+
+ test('rejects-work-after-terminate', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 1);
+
+ await pool.terminate();
+
+ expect(() => pool.run((_) async {}), throwsA(isA<StateError>()));
+ });
+
+ test('does-not-over-commit-a-connection-whose-limit-is-unknown', () async {
+ final dialGate = Completer<void>();
+ final workGate = Completer<void>();
+ final connections =
+ _Connections()
+ ..limit = 1
+ ..dialGate = dialGate.future;
+ final pool = _pool(
+ connections,
+ maxConcurrentStreams: 100,
+ maxIdleConnections: 10,
+ );
+
+ final inFlight = <int, int>{};
+ final peak = <int, int>{};
+ final ops = List.generate(
+ 8,
+ (_) => pool.run((c) async {
+ final index = connections.indexOf(c);
+ final now = (inFlight[index] ?? 0) + 1;
+ inFlight[index] = now;
+ peak[index] = max(peak[index] ?? 0, now);
+ await workGate.future;
+ inFlight[index] = inFlight[index]! - 1;
+ }),
+ );
+
+ dialGate.complete();
+ await pumpEventQueue();
+
+ expect(
+ peak.values,
+ everyElement(1),
+ reason: 'no connection may carry more streams than its limit of 1',
+ );
+ expect(peak, hasLength(8));
+
+ workGate.complete();
+ await Future.wait(ops);
+ });
+
+ test('tolerates-a-server-advertising-a-zero-limit', () async {
+ final connections = _Connections()..limit = 0;
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+
+ await expectLater(
+ pool.run((c) async => connections.indexOf(c)),
+ completion(0),
+ );
+ });
+
+ test('a-held-lease-keeps-its-slot', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 2);
+
+ final lease = await pool.acquire();
+ expect(pool.opCount, 1);
+
+ lease.release();
+ expect(pool.opCount, 0);
+ });
+
+ test('releasing-a-lease-twice-is-a-no-op', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 2);
+
+ final lease = await pool.acquire();
+ lease.release();
+ lease.release();
+
+ expect(pool.opCount, 0);
+ });
+
+ test('a-failed-lease-stops-the-connection-being-reused', () async {
+ final connections = _Connections();
+ final pool = _pool(connections, maxConcurrentStreams: 10);
+ final used = <int>[];
+
+ final lease = await pool.acquire();
+ used.add(connections.indexOf(lease.connection));
+ lease.markFailed();
+ lease.release();
+
+ await pool.run((c) async => used.add(connections.indexOf(c)));
+
+ expect(used, [0, 1]);
+ });
+
+ test('does-not-couple-a-stream-to-a-slow-close', () async {
+ final connections = _Connections()..closeGate = Completer<void>().future;
+ final pool = _pool(
+ connections,
+ maxConcurrentStreams: 1,
+ maxIdleConnections: 0,
+ );
+
+ await expectLater(pool.run((_) async => 'done'), completion('done'));
+ });
+
+ test('terminate-waits-for-a-close-started-by-collection', () async {
+ final gate = Completer<void>();
+ final connections = _Connections()..closeGate = gate.future;
+ final pool = _pool(
+ connections,
+ maxConcurrentStreams: 1,
+ maxIdleConnections: 0,
+ );
+
+ await pool.run((_) async {});
+ expect(connections.closed, [0]);
+
+ var terminated = false;
+ final termination = pool.terminate().then((_) => terminated = true);
+ await pumpEventQueue();
+ expect(terminated, isFalse, reason: 'the close has not finished yet');
+
+ gate.complete();
+ await termination;
+ expect(terminated, isTrue);
+ });
+
+ test('terminate-is-idempotent', () async {
+ final connections = _Connections();
+ final pool = _pool(connections, maxConcurrentStreams: 2);
+ final completer = Completer<void>();
+
+ unawaited(pool.run((_) => completer.future));
+
+ final first = pool.terminate();
+ final second = pool.terminate();
+ completer.complete();
+ await Future.wait([first, second]);
+
+ // Closed once, not once per terminate() call.
+ expect(connections.closed, [0]);
+ expect(pool.size, 0);
+ });
+
+ test('terminate-twice-does-not-throw-concurrent-modification', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 1);
+ final completers = List.generate(2, (_) => Completer<void>());
+
+ final ops = [
+ pool.run((_) => completers[0].future),
+ pool.run((_) => completers[1].future),
+ ];
+ await pumpEventQueue();
+ expect(pool.size, 2);
+
+ final terminations = [pool.terminate(), pool.terminate()];
+ for (final c in completers) {
+ c.complete();
+ }
+ await Future.wait(ops);
+
+ await expectLater(Future.wait(terminations), completes);
+ });
+
+ test('terminate-after-terminate-returns-immediately', () async {
+ final connections = _Connections();
+ final pool = _pool(connections, maxConcurrentStreams: 1);
+
+ await pool.run((_) async {});
+ await pool.terminate();
+ await pool.terminate();
+
+ expect(connections.closed, hasLength(lessThanOrEqualTo(1)));
+ });
+
+ test('waits-for-in-flight-streams-before-terminating', () async {
+ final pool = _pool(_Connections(), maxConcurrentStreams: 1);
+ final completer = Completer<void>();
+ var terminated = false;
+
+ unawaited(pool.run((_) => completer.future));
+ final terminateOp = pool.terminate().then((_) => terminated = true);
+
+ expect(terminated, isFalse);
+ completer.complete();
+ await terminateOp;
+ expect(terminated, isTrue);
+ });
+ });
+}
diff --git a/pkgs/http2/test/http2_client_test.dart b/pkgs/http2/test/http2_client_test.dart
new file mode 100644
index 0000000..37d9519
--- /dev/null
+++ b/pkgs/http2/test/http2_client_test.dart
@@ -0,0 +1,445 @@
+// Copyright (c) 2026, 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:io';
+import 'dart:math';
+
+import 'package:http/http.dart' show ClientException, Request;
+import 'package:http2/multiprotocol_server.dart';
+import 'package:http2/src/http2_client.dart';
+import 'package:http2/transport.dart';
+import 'package:test/test.dart';
+
+SecurityContext _serverContext() =>
+ SecurityContext()
+ ..useCertificateChain('test/certificates/server_chain.pem')
+ ..usePrivateKey('test/certificates/server_key.pem', password: 'dartdart');
+
+Future<MultiProtocolHttpServer> _bind() =>
+ MultiProtocolHttpServer.bind('localhost', 0, _serverContext());
+
+Http2Client _testClient({
+ int maxStreamsPerConnection = 100,
+ int maxIdleConnections = 1,
+}) => Http2Client(
+ maxStreamsPerConnection: maxStreamsPerConnection,
+ maxIdleConnections: maxIdleConnections,
+ onBadCertificate: (_) => true,
+);
+
+/// A minimal HTTP/2-only server that (unlike [MultiProtocolHttpServer])
+/// exposes each accepted [ServerTransportConnection], so a test can finish
+/// one connection gracefully while the server keeps listening for new ones.
+class _RawHttp2Server {
+ _RawHttp2Server._(
+ this._socket,
+ this._settings,
+ this._responseDelay,
+ this._bodyGate,
+ ) {
+ _socket.listen((socket) {
+ final connection = ServerTransportConnection.viaSocket(
+ socket,
+ settings: _settings,
+ );
+ connections.add(connection);
+ connection.incomingStreams.listen(
+ _respondWith('ok', delay: _responseDelay, bodyGate: _bodyGate),
+ );
+ });
+ }
+
+ /// [settings] defaults to the same value `ServerTransportConnection` would
+ /// have applied on its own, so callers that don't care are unaffected.
+ static Future<_RawHttp2Server> bind({
+ ServerSettings settings = const ServerSettings(concurrentStreamLimit: 1000),
+ Future<void>? responseDelay,
+ Future<void>? bodyGate,
+ }) async {
+ final context = _serverContext()..setAlpnProtocols(['h2'], true);
+ final socket = await SecureServerSocket.bind('localhost', 0, context);
+ return _RawHttp2Server._(socket, settings, responseDelay, bodyGate);
+ }
+
+ final SecureServerSocket _socket;
+ final ServerSettings _settings;
+ final Future<void>? _responseDelay;
+ final Future<void>? _bodyGate;
+ final connections = <ServerTransportConnection>[];
+
+ int get port => _socket.port;
+
+ Future<void> close() async {
+ await _socket.close();
+ for (final connection in connections) {
+ await connection.terminate();
+ }
+ }
+}
+
+/// Replies with [body] after waiting on [delay], if given.
+///
+/// [bodyGate] holds the response open *after* its headers have been sent, so a
+/// test can observe a request whose headers have arrived but whose stream is
+/// still open.
+void Function(ServerTransportStream) _respondWith(
+ String body, {
+ Future<void>? delay,
+ Future<void>? bodyGate,
+}) {
+ return (stream) async {
+ final subscription = StreamIterator(stream.incomingMessages);
+ await subscription.moveNext(); // Consume the request headers.
+ while (await subscription.moveNext()) {} // Drain any request body.
+
+ if (delay != null) await delay;
+
+ stream.outgoingMessages.add(
+ HeadersStreamMessage([Header.ascii(':status', '200')]),
+ );
+ if (bodyGate != null) await bodyGate;
+ try {
+ stream.outgoingMessages.add(DataStreamMessage(ascii.encode(body)));
+ await stream.outgoingMessages.close();
+ } catch (_) {}
+ };
+}
+
+void main() {
+ group('http2-client-test', () {
+ test('sends-request-and-receives-response', () async {
+ final server = await _bind();
+ server.startServing(
+ (request) {},
+ expectAsync1(_respondWith('hello'), count: 1),
+ );
+
+ final client = _testClient();
+ final response = await client.get(
+ Uri.parse('https://localhost:${server.port}/'),
+ );
+
+ expect(response.statusCode, 200);
+ expect(response.body, 'hello');
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('pools-connections-per-host-and-port', () async {
+ final serverA = await _bind();
+ final serverB = await _bind();
+ serverA.startServing(
+ (request) {},
+ expectAsync1(_respondWith('a'), count: 1),
+ );
+ serverB.startServing(
+ (request) {},
+ expectAsync1(_respondWith('b'), count: 1),
+ );
+
+ final client = _testClient();
+ await Future.wait([
+ client.get(Uri.parse('https://localhost:${serverA.port}/')),
+ client.get(Uri.parse('https://localhost:${serverB.port}/')),
+ ]);
+
+ expect(client.connectionCount, 2);
+
+ client.close();
+ await client.closed;
+ await Future.wait([serverA.close(), serverB.close()]);
+ });
+
+ test('exceeding-max-streams-per-connection-opens-new-connection', () async {
+ final server = await _bind();
+ final releaseA = Completer<void>();
+ final releaseB = Completer<void>();
+ var requestNr = 0;
+ server.startServing(
+ (request) {},
+ expectAsync1((stream) {
+ final release = requestNr++ == 0 ? releaseA : releaseB;
+ return _respondWith('r', delay: release.future)(stream);
+ }, count: 2),
+ );
+
+ final client = _testClient(maxStreamsPerConnection: 1);
+ final requestA = client.get(
+ Uri.parse('https://localhost:${server.port}/a'),
+ );
+ await Future<void>.delayed(const Duration(milliseconds: 50));
+ final requestB = client.get(
+ Uri.parse('https://localhost:${server.port}/b'),
+ );
+
+ await Future<void>.delayed(const Duration(milliseconds: 50));
+ expect(client.connectionCount, 2);
+
+ releaseA.complete();
+ releaseB.complete();
+ await Future.wait([requestA, requestB]);
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('retries-once-when-pooled-connection-was-closed-by-peer', () async {
+ final server = await _RawHttp2Server.bind();
+ final client = _testClient();
+
+ final r1 = await client.get(
+ Uri.parse('https://localhost:${server.port}/'),
+ );
+ expect(r1.statusCode, 200);
+ expect(client.connectionCount, 1);
+
+ await server.connections.single.finish();
+ await Future<void>.delayed(const Duration(milliseconds: 200));
+
+ final r2 = await client.get(
+ Uri.parse('https://localhost:${server.port}/'),
+ );
+ expect(r2.statusCode, 200);
+ expect(r2.body, 'ok');
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('respects-server-advertised-max-concurrent-streams', () async {
+ final release = Completer<void>();
+ final server = await _RawHttp2Server.bind(
+ settings: const ServerSettings(concurrentStreamLimit: 1),
+ responseDelay: release.future,
+ );
+ final client = _testClient(
+ maxStreamsPerConnection: 100,
+ maxIdleConnections: 5,
+ );
+
+ final requestA = client.get(
+ Uri.parse('https://localhost:${server.port}/a'),
+ );
+ await Future<void>.delayed(const Duration(milliseconds: 100));
+ final requestB = client.get(
+ Uri.parse('https://localhost:${server.port}/b'),
+ );
+ await Future<void>.delayed(const Duration(milliseconds: 100));
+
+ release.complete();
+ final responses = await Future.wait([requestA, requestB]);
+ expect(responses.map((r) => r.statusCode), everyElement(200));
+ expect(server.connections, hasLength(2));
+
+ expect(client.connectionCount, 2);
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('holds-a-pool-slot-until-the-response-body-completes', () async {
+ final gate = Completer<void>();
+ final server = await _bind();
+ server.startServing(
+ (request) {},
+ expectAsync1(_respondWith('ok', bodyGate: gate.future), count: 2),
+ );
+
+ final client = _testClient(maxStreamsPerConnection: 1);
+ final url = Uri.parse('https://localhost:${server.port}/');
+ final first = await client.send(Request('GET', url));
+ final second = await client.send(Request('GET', url));
+
+ expect(client.connectionCount, 2);
+
+ gate.complete();
+ expect(await first.stream.bytesToString(), 'ok');
+ expect(await second.stream.bytesToString(), 'ok');
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('releases-the-slot-when-the-response-body-is-cancelled', () async {
+ final gate = Completer<void>();
+ final server = await _bind();
+ var streamNr = 0;
+ server.startServing(
+ (request) {},
+ expectAsync1((stream) {
+ final held = streamNr++ == 0 ? gate.future : null;
+ return _respondWith('ok', bodyGate: held)(stream);
+ }, count: 2),
+ );
+
+ final client = _testClient(maxStreamsPerConnection: 1);
+ final url = Uri.parse('https://localhost:${server.port}/');
+
+ final first = await client.send(Request('GET', url));
+ await first.stream.listen((_) {}).cancel();
+
+ final second = await client.get(url);
+ expect(second.statusCode, 200);
+ expect(client.connectionCount, 1);
+
+ gate.complete();
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('releases-the-slot-when-the-response-body-errors', () async {
+ final gate = Completer<void>();
+ final server = await _RawHttp2Server.bind(bodyGate: gate.future);
+ final client = _testClient(maxStreamsPerConnection: 1);
+ final url = Uri.parse('https://localhost:${server.port}/');
+
+ final first = await client.send(Request('GET', url));
+ await server.connections.single.terminate();
+ await expectLater(
+ first.stream.drain<void>(),
+ throwsA(isA<ClientException>()),
+ );
+
+ gate.complete();
+
+ final second = await client.get(url);
+ expect(second.statusCode, 200);
+
+ client.close();
+ await client.closed;
+ await server.close();
+ });
+
+ test('does-not-exceed-the-server-stream-limit-on-a-cold-burst', () async {
+ const streamLimit = 2;
+ const requestCount = 12;
+ final release = Completer<void>();
+ final context = _serverContext()..setAlpnProtocols(['h2'], true);
+ final socket = await SecureServerSocket.bind('localhost', 0, context);
+
+ final active = <ServerTransportConnection, int>{};
+ final peak = <ServerTransportConnection, int>{};
+ socket.listen((raw) {
+ final connection = ServerTransportConnection.viaSocket(
+ raw,
+ settings: const ServerSettings(concurrentStreamLimit: streamLimit),
+ );
+ connection.incomingStreams.listen((stream) async {
+ final now = (active[connection] ?? 0) + 1;
+ active[connection] = now;
+ peak[connection] = max(peak[connection] ?? 0, now);
+
+ final messages = StreamIterator(stream.incomingMessages);
+ await messages.moveNext();
+ while (await messages.moveNext()) {}
+ await release.future;
+ stream.outgoingMessages.add(
+ HeadersStreamMessage([Header.ascii(':status', '200')]),
+ );
+ stream.outgoingMessages.add(DataStreamMessage(ascii.encode('ok')));
+ await stream.outgoingMessages.close();
+
+ active[connection] = active[connection]! - 1;
+ });
+ });
+
+ final client = _testClient(maxStreamsPerConnection: 100);
+ final url = Uri.parse('https://localhost:${socket.port}/');
+ final requests = List.generate(requestCount, (_) => client.get(url));
+
+ await pumpEventQueue();
+ release.complete();
+ final responses = await Future.wait(requests);
+
+ expect(responses.map((r) => r.statusCode), everyElement(200));
+ expect(
+ peak.values,
+ everyElement(lessThanOrEqualTo(streamLimit)),
+ reason: 'no connection may carry more streams than the server allows',
+ );
+
+ client.close();
+ await client.closed;
+ await socket.close();
+ });
+
+ test('fails-the-dial-when-the-peer-closes-before-settings', () async {
+ final context = _serverContext()..setAlpnProtocols(['h2'], true);
+ final socket = await SecureServerSocket.bind('localhost', 0, context);
+ socket.listen((connection) => connection.destroy());
+
+ final client = _testClient();
+ await expectLater(
+ client.get(Uri.parse('https://localhost:${socket.port}/')),
+ throwsA(isA<ClientException>()),
+ );
+
+ client.close();
+ await client.closed;
+ await socket.close();
+ });
+
+ test('fails-the-dial-when-the-peer-never-sends-settings', () async {
+ final context = _serverContext()..setAlpnProtocols(['h2'], true);
+ final socket = await SecureServerSocket.bind('localhost', 0, context);
+ final held = <SecureSocket>[];
+ socket.listen(held.add);
+
+ final client = Http2Client(
+ onBadCertificate: (_) => true,
+ settingsTimeout: const Duration(milliseconds: 200),
+ );
+ await expectLater(
+ client.get(Uri.parse('https://localhost:${socket.port}/')),
+ throwsA(isA<ClientException>()),
+ );
+
+ client.close();
+ await client.closed;
+ for (final connection in held) {
+ connection.destroy();
+ }
+ await socket.close();
+ });
+
+ test('close-waits-for-in-flight-request', () async {
+ final server = await _bind();
+ final release = Completer<void>();
+ server.startServing(
+ (request) {},
+ expectAsync1(_respondWith('done', delay: release.future), count: 1),
+ );
+
+ final client = _testClient();
+ final request = client.get(
+ Uri.parse('https://localhost:${server.port}/'),
+ );
+
+ var terminated = false;
+ client.close();
+ final terminateFuture = client.closed.then((_) {
+ terminated = true;
+ });
+
+ await Future<void>.delayed(const Duration(milliseconds: 50));
+ expect(terminated, isFalse);
+
+ release.complete();
+ await request;
+ await terminateFuture;
+ expect(terminated, isTrue);
+
+ await server.close();
+ });
+ });
+}
diff --git a/pkgs/http2/test/pool_mocks.dart b/pkgs/http2/test/pool_mocks.dart
new file mode 100644
index 0000000..4272255
--- /dev/null
+++ b/pkgs/http2/test/pool_mocks.dart
@@ -0,0 +1,9 @@
+// Copyright (c) 2026, 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/connection.dart';
+import 'package:mockito/annotations.dart';
+
+@GenerateMocks([ClientConnection])
+void main() {}
diff --git a/pkgs/http2/test/pool_mocks.mocks.dart b/pkgs/http2/test/pool_mocks.mocks.dart
new file mode 100644
index 0000000..86ac3e4
--- /dev/null
+++ b/pkgs/http2/test/pool_mocks.mocks.dart
@@ -0,0 +1,154 @@
+// Mocks generated by Mockito 5.4.6 from annotations
+// in http2/test/pool_mocks.dart.
+// Do not manually edit this file.
+
+// ignore_for_file: no_leading_underscores_for_library_prefixes
+import 'dart:async' as _i5;
+
+import 'package:http2/src/connection.dart' as _i4;
+import 'package:http2/src/settings/settings.dart' as _i2;
+import 'package:http2/transport.dart' as _i3;
+import 'package:mockito/mockito.dart' as _i1;
+
+// 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: deprecated_member_use
+// ignore_for_file: deprecated_member_use_from_same_package
+// ignore_for_file: implementation_imports
+// ignore_for_file: invalid_use_of_visible_for_testing_member
+// ignore_for_file: must_be_immutable
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: unnecessary_parenthesis
+// ignore_for_file: camel_case_types
+// ignore_for_file: subtype_of_sealed_class
+// ignore_for_file: invalid_use_of_internal_member
+
+class _FakeActiveSettings_0 extends _i1.SmartFake
+ implements _i2.ActiveSettings {
+ _FakeActiveSettings_0(Object parent, Invocation parentInvocation)
+ : super(parent, parentInvocation);
+}
+
+class _FakeClientTransportStream_1 extends _i1.SmartFake
+ implements _i3.ClientTransportStream {
+ _FakeClientTransportStream_1(Object parent, Invocation parentInvocation)
+ : super(parent, parentInvocation);
+}
+
+/// A class which mocks [ClientConnection].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockClientConnection extends _i1.Mock implements _i4.ClientConnection {
+ MockClientConnection() {
+ _i1.throwOnMissingStub(this);
+ }
+
+ @override
+ bool get isOpen =>
+ (super.noSuchMethod(Invocation.getter(#isOpen), returnValue: false)
+ as bool);
+
+ @override
+ _i5.Stream<int> get onPingReceived =>
+ (super.noSuchMethod(
+ Invocation.getter(#onPingReceived),
+ returnValue: _i5.Stream<int>.empty(),
+ )
+ as _i5.Stream<int>);
+
+ @override
+ _i5.Stream<void> get onFrameReceived =>
+ (super.noSuchMethod(
+ Invocation.getter(#onFrameReceived),
+ returnValue: _i5.Stream<void>.empty(),
+ )
+ as _i5.Stream<void>);
+
+ @override
+ _i2.ActiveSettings get acknowledgedSettings =>
+ (super.noSuchMethod(
+ Invocation.getter(#acknowledgedSettings),
+ returnValue: _FakeActiveSettings_0(
+ this,
+ Invocation.getter(#acknowledgedSettings),
+ ),
+ )
+ as _i2.ActiveSettings);
+
+ @override
+ _i2.ActiveSettings get peerSettings =>
+ (super.noSuchMethod(
+ Invocation.getter(#peerSettings),
+ returnValue: _FakeActiveSettings_0(
+ this,
+ Invocation.getter(#peerSettings),
+ ),
+ )
+ as _i2.ActiveSettings);
+
+ @override
+ bool get isClientConnection =>
+ (super.noSuchMethod(
+ Invocation.getter(#isClientConnection),
+ returnValue: false,
+ )
+ as bool);
+
+ @override
+ _i5.Future<void> get onInitialPeerSettingsReceived =>
+ (super.noSuchMethod(
+ Invocation.getter(#onInitialPeerSettingsReceived),
+ returnValue: _i5.Future<void>.value(),
+ )
+ as _i5.Future<void>);
+
+ @override
+ set onActiveStateChanged(_i3.ActiveStateHandler? value) => super.noSuchMethod(
+ Invocation.setter(#onActiveStateChanged, value),
+ returnValueForMissingStub: null,
+ );
+
+ @override
+ _i3.ClientTransportStream makeRequest(
+ List<_i3.Header>? headers, {
+ bool? endStream = false,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#makeRequest, [headers], {#endStream: endStream}),
+ returnValue: _FakeClientTransportStream_1(
+ this,
+ Invocation.method(
+ #makeRequest,
+ [headers],
+ {#endStream: endStream},
+ ),
+ ),
+ )
+ as _i3.ClientTransportStream);
+
+ @override
+ _i5.Future<Object?> ping() =>
+ (super.noSuchMethod(
+ Invocation.method(#ping, []),
+ returnValue: _i5.Future<Object?>.value(),
+ )
+ as _i5.Future<Object?>);
+
+ @override
+ _i5.Future<Object?> finish() =>
+ (super.noSuchMethod(
+ Invocation.method(#finish, []),
+ returnValue: _i5.Future<Object?>.value(),
+ )
+ as _i5.Future<Object?>);
+
+ @override
+ _i5.Future<Object?> terminate([int? errorCode, String? message]) =>
+ (super.noSuchMethod(
+ Invocation.method(#terminate, [errorCode, message]),
+ returnValue: _i5.Future<Object?>.value(),
+ )
+ as _i5.Future<Object?>);
+}