blob: 37d95190afc80c33836b5b38dc9cc39d647d8e26 [file]
// 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();
});
});
}