Skip to content

http2_adapter: 1xx informational responses (e.g. 103 Early Hints) crash the request #2600

Description

@GrishMahat

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    h: need triageThis issue needs to be categorizeds: bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions