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?>);
+}