Package
http2_adapter
Version
2.9.0
Operating-System
Linux
Adapter
Http2Adapter
Output of flutter doctor -v
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.47.1, on Arch Linux 7.1.9-zen1-2-zen, locale en_US.UTF-8)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
! Multiple adb binaries found. This can cause conflicts and device detection issues:
- /home/grish/Android/Sdk/platform-tools/adb
- /usr/bin/adb
[✗] Chrome - develop for the web (Cannot find Chrome executable at google-chrome)
! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.
[✓] Linux toolchain - develop for Linux desktop
[✓] Connected device (1 available)
[✓] Network resources
Dart Version
Dart SDK version: 3.13.1 (stable) (Tue Aug 18 01:00:59 2026 -0700) on "linux_x64"
Steps to Reproduce
Self-contained, no external server needed (h2c works over plain http://):
dependencies:
dio: ^5.2.0
dio_http2_adapter: ^2.9.0
http2: ^2.3.0
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_http2_adapter/dio_http2_adapter.dart';
import 'package:http2/http2.dart';
Future<void> main() async {
final server = await _EarlyHintsServer.bind();
final dio = Dio();
dio.httpClientAdapter = Http2Adapter(ConnectionManager());
try {
final res = await dio.get<String>(
'http://127.0.0.1:${server.port}/',
options: Options(responseType: ResponseType.plain),
);
print('STATUS: ${res.statusCode}');
} on DioException catch (e) {
print('DioException type=${e.type} status=${e.response?.statusCode}');
}
await Future<void>.delayed(const Duration(seconds: 1));
await server.close();
}
class _EarlyHintsServer {
_EarlyHintsServer._(this._socket, this._connections);
final ServerSocket _socket;
final List<ServerTransportConnection> _connections;
int get port => _socket.port;
static Future<_EarlyHintsServer> bind() async {
final connections = <ServerTransportConnection>[];
final socket = await ServerSocket.bind('127.0.0.1', 0);
socket.listen((client) {
final connection = ServerTransportConnection.viaSocket(client);
connections.add(connection);
connection.incomingStreams.listen((stream) {
stream.sendHeaders([
Header.ascii(':status', '103'),
Header.ascii('link', '</s.css>; rel=preload; as=style'),
], endStream: false);
stream.sendHeaders([
Header.ascii(':status', '200'),
Header.ascii('content-type', 'text/plain'),
], endStream: false);
stream.sendData('hello'.codeUnits, endStream: true);
});
});
return _EarlyHintsServer._(socket, connections);
}
Future<void> close() async {
for (final c in _connections) {
await c.finish();
}
await _socket.close();
}
}
Crashes every run.
Expected Result
dio should ignore the 103 and resolve with the final 200 response and its body (hello). RFC 9110 §15.2 requires clients to tolerate one or more interim 1xx responses before the final response on the same stream the 103 here isn't the response to act on.
Actual Result
dio treats the 103 as the final response. It resolves with a DioException of type: badResponse and statusCode: 103 (103 fails the default validateStatus check). When the real final HEADERS frame (:status: 200) then arrives on the same stream, the adapter tries to complete the same Completer a second time and throws an unhandled exception that crashes the isolate:
Unhandled exception:
Bad state: Future already completed
#0 _AsyncCompleter.complete (dart:async/future_impl.dart:97:31)
#1 Http2Adapter._fetch.<anonymous closure> (package:dio_http2_adapter/src/http2_adapter.dart:236:31)
CDNs don't send Early Hints on every response, so this fails intermittently the same endpoint succeeds most of the time and crashes whenever a 103 happens to precede the 200.
Cause, lib/src/http2_adapter.dart lines 228–236 (same in 2.8.0, 2.9.0, and main):
final status = responseHeaders.value(':status');
if (status != null) {
statusCode = int.parse(status);
responseHeaders.removeAll(':status');
needRedirect = _needRedirect(options, statusCode);
needResponse =
!needRedirect && options.validateStatus(statusCode) ||
options.receiveDataWhenStatusError;
responseCompleter.complete(); // fires for the 103, then again for the 200
}
validateStatus and the ConnectionManager/ClientSetting options don't touch this path the double-complete is unconditional. A secondary effect of the same code: nothing resets responseHeaders between HEADERS frames, only :status gets removed, so headers from the 103 (like link) also leak into whatever ends up in the final response.
I'd propose skipping 1xx HEADERS frames (other than 101, which HTTP/2 has no use for) instead of completing on them, and clearing responseHeaders when a new HEADERS frame starts so interim headers don't carry over.
Package
http2_adapter
Version
2.9.0
Operating-System
Linux
Adapter
Http2Adapter
Output of
flutter doctor -vDart Version
Dart SDK version: 3.13.1 (stable) (Tue Aug 18 01:00:59 2026 -0700) on "linux_x64"
Steps to Reproduce
Self-contained, no external server needed (h2c works over plain
http://):Crashes every run.
Expected Result
dio should ignore the
103and resolve with the final200response and its body (hello). RFC 9110 §15.2 requires clients to tolerate one or more interim 1xx responses before the final response on the same stream the103here isn't the response to act on.Actual Result
dio treats the
103as the final response. It resolves with aDioExceptionoftype: badResponseandstatusCode: 103(103 fails the defaultvalidateStatuscheck). When the real final HEADERS frame (:status: 200) then arrives on the same stream, the adapter tries to complete the sameCompletera second time and throws an unhandled exception that crashes the isolate:CDNs don't send Early Hints on every response, so this fails intermittently the same endpoint succeeds most of the time and crashes whenever a
103happens to precede the200.Cause,
lib/src/http2_adapter.dartlines 228–236 (same in 2.8.0, 2.9.0, and main):validateStatusand theConnectionManager/ClientSettingoptions don't touch this path the double-complete is unconditional. A secondary effect of the same code: nothing resetsresponseHeadersbetween HEADERS frames, only:statusgets removed, so headers from the103(likelink) also leak into whatever ends up in the final response.I'd propose skipping 1xx HEADERS frames (other than 101, which HTTP/2 has no use for) instead of completing on them, and clearing
responseHeaderswhen a new HEADERS frame starts so interim headers don't carry over.