blob: 3e5a84a48477188e8bbf83e38be57390027c0920 [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: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);
});
});
}