Repository: cfug/dio

Issue:
Make `CancelToken` be able to cancel response streams from `ResponseType.stream` requests
### Package

dio

### Version

5.3.3

### Operating-System

Linux

### Output of `flutter doctor -v`

```shell
[✓] Flutter (Channel stable, 3.13.9, on Ubuntu 23.04 6.2.0-36-generic, locale en_US.UTF-8)
    • Flutter version 3.13.9 on channel stable at /opt/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision d211f42860 (12 days ago), 2023-10-25 13:42:25 -0700
    • Engine revision 0545f8705d
    • Dart version 3.1.5
    • DevTools version 2.25.0

[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
    • Android SDK at /home/tomek/Android/Sdk
    • Platform android-34, build-tools 33.0.1
    • ANDROID_HOME = /home/tomek/Android/Sdk
    • Java binary at: /opt/android-studio/jbr/bin/java
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)
    • All Android licenses accepted.

[✓] Chrome - develop for the web
    • Chrome at google-chrome

[✓] Linux toolchain - develop for Linux desktop
    • Ubuntu clang version 15.0.7
    • cmake version 3.25.1
    • ninja version 1.11.1
    • pkg-config version 1.8.1

[✓] Android Studio (version 2022.3)
    • Android Studio at /opt/android-studio
    • Flutter plugin version 75.1.2
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)

[✓] VS Code (version 1.78.2)
    • VS Code at /usr/share/code
    • Flutter extension can be installed from:
      🔨 https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter

[✓] Connected device (2 available)
    • Linux (desktop) • linux  • linux-x64      • Ubuntu 23.04 6.2.0-36-generic
    • Chrome (web)    • chrome • web-javascript • Google Chrome 114.0.5735.198

[✓] Network resources
    • All expected network resources are available.

• No issues found!
```


### Dart Version

3.1.5

### Steps to Reproduce

Run: https://github.com/tomekit/dio_cancel_issue_stream_get

or use below `main.dart` file:

<details>
<summary>main.dart</summary>

```dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dio cancellation issue',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurpleAccent),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Dio cancellation issue'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  int _downloaded = 0;
  bool _running = false;
  Exception? _failed;
  CancelToken cancelToken = CancelToken();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(widget.title),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('_downloaded: ${_downloaded}, _running: ${_running}, _failed: ${_failed?.toString()},')
            ],
          ),
        ),
        floatingActionButton:
        Stack(
          children: <Widget>[
            Align(
              alignment: Alignment.bottomLeft,
              child: FloatingActionButton(
                onPressed: () async {
                  fetch(ResponseType.bytes);
                },
                tooltip: 'Start bytes',
                child: const Icon(Icons.start),
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: FloatingActionButton(
                onPressed: () async {
                  fetch(ResponseType.stream);
                },
                tooltip: 'Start stream',
                child: const Icon(Icons.start),
              ),
            ),
            Align(
              alignment: Alignment.bottomRight,
              child: FloatingActionButton(
                onPressed: () => cancelToken.cancel(),
                tooltip: 'Cancel',
                child: const Icon(Icons.cancel),
              ),
            ),
          ],
        )
    );
  }

  fetch(ResponseType responseType) async {
    final url = "http://ash-speed.hetzner.com/100MB.bin";

    final dio = Dio();

    final options = Options(
      // responseType: ResponseType.bytes, // Works fine
      // responseType: ResponseType.stream, // No effect
      responseType: responseType
    );

    // For ResponseType.bytes purposes
    onReceiveProgress(int sent, int __) {
      setState(() {
        _downloaded = sent;
      });
      return;
    }

    try {

      setState(() {
        _running = true;
        _failed = null;
        cancelToken = CancelToken();
      });

      final response = await dio.get(url, options: options, cancelToken: cancelToken, onReceiveProgress: onReceiveProgress);
      final status = response.statusCode;

      var downloadedTotalStream = 0;
      if (status == 200) {
        await for (final List<int> dataChunk in response.data.stream) {
          downloadedTotalStream += dataChunk.length;
          setState(() {
            _downloaded = downloadedTotalStream;
          });
        }
      } else {
        setState(() {
          _failed = Exception("Status code: $status");
        });
      }

    } catch(e) {
      setState(() {
        _failed = e as Exception;
      });
    }

    setState(() {
      _running = false;
    });
  }
}
```

</details>

Click middle "Start stream" button to start `streamed` download, then click bottom right: "Cancel" button. Nothing happens in the release mode. In the debug mode your IDE (in my case Android Studio) may (it doesn't happen consistently) capture uncaught exception.
![Screenshot from 2023-11-11 11-04-29](https://github.com/cfug/dio/assets/7759113/598e598a-b2cf-48d0-b6d2-bdba84e6db85)

That's unexpected behavior, request doesn't get cancelled.

Click bottom left: "Start bytes" button to start `bytes` download, then click bottom right: "Cancel" button. DioException is thrown. That's expected behavior, request cancelling takes place.

### Expected Result

`DioException` shall be thrown with `type` set to `DioExceptionType.cancel` even if: `responseType: ResponseType.stream` is used.

### Actual Result

`DioException` isn't thrown when request with `responseType: ResponseType.stream` gets cancelled.

Test app:
![image](https://github.com/cfug/dio/assets/7759113/2faa8ba4-3f91-4c04-8e44-fa5e971e5ec0)



Discussion: I agree, thanks for the reproduction. I think I have a solution but testing this is kinda hard.
Do you think https://github.com/cfug/dio/issues/1953 is the same problem?
I've been investigated about the cause of the issue.

When we request stream responses, the response body returns immediately.
https://github.com/cfug/dio/blob/bedcc54c91a800a3b1aac0092151ca401f716803/dio/lib/src/transformers/sync_transformer.dart#L60-L63

When you get the response, the request flow is finished. Nothing in the queue was still awaiting, thus the cancel token is no longer effective (because the request is, by a specific aspect, ended). From the flow, we can say the behavior is as desired.

Given the above fact, because you have already obtained the stream, you can use other mechanisms rather than cancel tokens to stop the stream reading.

The issue is nothing related to #1953 and #2028. I understand everyone's issue is urgent to themselves, but referencing unrelated issues will cause redundant information for people who subscribed to those issues, and increase the complexibility for maintainers to find the root cause.

Still, I've tried to improve this and managed to throw the cancel exception for the stream.
![image](https://github.com/cfug/dio/assets/15884415/eeb46c82-dd56-4014-a66a-aabdb522c797)
But it doesn't look like a proper way to solve it because it depends on implementation in adapters. So I gonna leave this open and continue to investigate to see if we can have a proper solution.
@AlexV525 I already had some changes and tests for this - see #2032 

Fix patch:
diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md
index ccbcf2533..b4913ed83 100644
--- a/dio/CHANGELOG.md
+++ b/dio/CHANGELOG.md
@@ -7,6 +7,8 @@ See the [Migration Guide][] for the complete breaking changes list.**
 
 - Provide fix suggestions for `dart fix`.
 - Fix `receiveTimeout` for streamed responses.
+- Fix cancellation for streamed responses and downloads when using `IOHttpClientAdapter`.
+- Fix receive progress for streamed responses and downloads when using `IOHttpClientAdapter`.
 
 ## 5.4.0
 
diff --git a/dio/lib/src/adapter.dart b/dio/lib/src/adapter.dart
index 7d3b589a3..561bbf9d2 100644
--- a/dio/lib/src/adapter.dart
+++ b/dio/lib/src/adapter.dart
@@ -1,6 +1,9 @@
 import 'dart:convert';
 import 'dart:typed_data';
 
+import 'package:meta/meta.dart';
+
+import 'headers.dart';
 import 'options.dart';
 import 'redirect_record.dart';
 
@@ -57,28 +60,34 @@ class ResponseBody {
     this.statusMessage,
     this.isRedirect = false,
     this.redirects,
+    void Function()? onClose,
     Map<String, List<String>>? headers,
-  }) : headers = headers ?? {};
+  })  : headers = headers ?? {},
+        _onClose = onClose;
 
   ResponseBody.fromString(
     String text,
     this.statusCode, {
     this.statusMessage,
     this.isRedirect = false,
+    void Function()? onClose,
     Map<String, List<String>>? headers,
   })  : stream = Stream.value(Uint8List.fromList(utf8.encode(text))),
-        headers = headers ?? {};
+        headers = headers ?? {},
+        _onClose = onClose;
 
   ResponseBody.fromBytes(
     List<int> bytes,
     this.statusCode, {
     this.statusMessage,
     this.isRedirect = false,
+    void Function()? onClose,
     Map<String, List<String>>? headers,
   })  : stream = Stream.value(
           bytes is Uint8List ? bytes : Uint8List.fromList(bytes),
         ),
-        headers = headers ?? {};
+        headers = headers ?? {},
+        _onClose = onClose;
 
   /// Whether this response is a redirect.
   final bool isRedirect;
@@ -89,6 +98,10 @@ class ResponseBody {
   /// HTTP status code.
   int statusCode;
 
+  /// Content length of the response or -1 if not specified
+  int get contentLength =>
+      int.parse(headers[Headers.contentLengthHeader]?.first ?? '-1');
+
   /// Returns the reason phrase corresponds to the status code.
   /// The message can be [HttpRequest.statusText]
   /// or [HttpClientResponse.reasonPhrase].
@@ -102,4 +115,10 @@ class ResponseBody {
 
   /// The extra field which will pass-through to the [Response.extra].
   Map<String, dynamic> extra = {};
+
+  final void Function()? _onClose;
+
+  /// Closes the request & frees the underlying resources.
+  @internal
+  void close() => _onClose?.call();
 }
diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart
index ab1d76dc9..63a2d2b1c 100644
--- a/dio/lib/src/adapters/io_adapter.dart
+++ b/dio/lib/src/adapters/io_adapter.dart
@@ -75,9 +75,7 @@ class IOHttpClientAdapter implements HttpClientAdapter {
       requestStream,
       cancelFuture,
     ));
-    if (cancelFuture != null) {
-      cancelFuture.whenComplete(() => operation.cancel());
-    }
+    cancelFuture?.whenComplete(() => operation.cancel());
     return operation.value;
   }
 
@@ -106,9 +104,7 @@ class IOHttpClientAdapter implements HttpClientAdapter {
         request = await reqFuture;
       }
 
-      if (cancelFuture != null) {
-        cancelFuture.whenComplete(() => request.abort());
-      }
+      cancelFuture?.whenComplete(() => request.abort());
 
       // Set Headers
       options.headers.forEach((key, value) {
@@ -202,69 +198,12 @@ class IOHttpClientAdapter implements HttpClientAdapter {
       }
     }
 
-    // Use a StreamController to explicitly handle receive timeouts.
-    final responseSink = StreamController<Uint8List>();
-    late StreamSubscription<List<int>> responseSubscription;
-
-    final receiveStopwatch = Stopwatch();
-    Timer? receiveTimer;
-
-    void stopWatchReceiveTimeout() {
-      receiveTimer?.cancel();
-      receiveTimer = null;
-      receiveStopwatch.stop();
-    }
-
-    void watchReceiveTimeout() {
-      if (receiveTimeout <= Duration.zero) {
-        return;
-      }
-      receiveStopwatch.reset();
-      if (!receiveStopwatch.isRunning) {
-        receiveStopwatch.start();
-      }
-      receiveTimer?.cancel();
-      receiveTimer = Timer(receiveTimeout, () {
-        responseSink.addError(
-          DioException.receiveTimeout(
-            timeout: receiveTimeout,
-            requestOptions: options,
-          ),
-        );
-        responseSink.close();
-        responseSubscription.cancel();
-        responseStream.detachSocket().then((socket) => socket.destroy());
-        stopWatchReceiveTimeout();
-      });
-    }
-
-    responseSubscription = responseStream.cast<Uint8List>().listen(
-      (data) {
-        watchReceiveTimeout();
-        // Always true if the receive timeout was not set.
-        if (receiveStopwatch.elapsed <= receiveTimeout) {
-          responseSink.add(data);
-        }
-      },
-      onError: (error, stackTrace) {
-        stopWatchReceiveTimeout();
-        responseSink.addError(error, stackTrace);
-        responseSink.close();
-      },
-      onDone: () {
-        stopWatchReceiveTimeout();
-        responseSubscription.cancel();
-        responseSink.close();
-      },
-      cancelOnError: true,
-    );
-
     final headers = <String, List<String>>{};
     responseStream.headers.forEach((key, values) {
       headers[key] = values;
     });
     return ResponseBody(
-      responseSink.stream,
+      responseStream.cast(),
       responseStream.statusCode,
       headers: headers,
       isRedirect:
@@ -273,6 +212,9 @@ class IOHttpClientAdapter implements HttpClientAdapter {
           .map((e) => RedirectRecord(e.statusCode, e.method, e.location))
           .toList(),
       statusMessage: responseStream.reasonPhrase,
+      onClose: () {
+        responseStream.detachSocket().then((socket) => socket.destroy());
+      },
     );
   }
 
diff --git a/dio/lib/src/dio/dio_for_native.dart b/dio/lib/src/dio/dio_for_native.dart
index a725abdec..d034ea837 100644
--- a/dio/lib/src/dio/dio_for_native.dart
+++ b/dio/lib/src/dio/dio_for_native.dart
@@ -48,7 +48,7 @@ class DioForNative with DioMixin implements Dio {
         data: data,
         options: options,
         queryParameters: queryParameters,
-        cancelToken: cancelToken ?? CancelToken(),
+        cancelToken: cancelToken,
       );
     } on DioException catch (e) {
       if (e.type == DioExceptionType.badResponse) {
diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart
index b3bbaabbe..8a4b84c7c 100644
--- a/dio/lib/src/dio_mixin.dart
+++ b/dio/lib/src/dio_mixin.dart
@@ -15,6 +15,7 @@ import 'headers.dart';
 import 'interceptors/imply_content_type.dart';
 import 'options.dart';
 import 'response.dart';
+import 'response/response_stream_handler.dart';
 import 'transformer.dart';
 import 'transformers/background_transformer.dart';
 
@@ -535,6 +536,8 @@ abstract class DioMixin implements Dio {
       );
       final statusOk = reqOpt.validateStatus(responseBody.statusCode);
       if (statusOk || reqOpt.receiveDataWhenStatusError == true) {
+        responseBody.stream = handleResponseStream(reqOpt, responseBody);
+
         Object? data = await transformer.transformResponse(
           reqOpt,
           responseBody,
@@ -549,7 +552,7 @@ abstract class DioMixin implements Dio {
         }
         ret.data = data;
       } else {
-        await responseBody.stream.listen(null).cancel();
+        responseBody.close();
       }
       checkCancelled(cancelToken);
       if (statusOk) {
diff --git a/dio/lib/src/response/response_stream_handler.dart b/dio/lib/src/response/response_stream_handler.dart
new file mode 100644
index 000000000..5a5e4b903
--- /dev/null
+++ b/dio/lib/src/response/response_stream_handler.dart
@@ -0,0 +1,96 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:dio/dio.dart';
+
+/// An internal helper which handles functionality
+/// common to all adapters. This function ensures that
+/// all resources are closed when the request is finished
+/// or cancelled.
+///
+/// - [options.receiveTimeout] between received chunks
+/// - [options.onReceiveProgress] progress for received chunks
+/// - [options.cancelToken] for cancellation while receiving
+Stream<Uint8List> handleResponseStream(
+  RequestOptions options,
+  ResponseBody response,
+) {
+  final source = response.stream;
+  final responseSink = StreamController<Uint8List>();
+  late StreamSubscription<List<int>> responseSubscription;
+
+  late int totalLength;
+  int receivedLength = 0;
+  if (options.onReceiveProgress != null) {
+    totalLength = response.contentLength;
+  }
+
+  final receiveTimeout = options.receiveTimeout ?? Duration.zero;
+  final receiveStopwatch = Stopwatch();
+  Timer? receiveTimer;
+
+  void stopWatchReceiveTimeout() {
+    receiveTimer?.cancel();
+    receiveTimer = null;
+    receiveStopwatch.stop();
+  }
+
+  void watchReceiveTimeout() {
+    if (receiveTimeout <= Duration.zero) {
+      return;
+    }
+    receiveStopwatch.reset();
+    if (!receiveStopwatch.isRunning) {
+      receiveStopwatch.start();
+    }
+    receiveTimer?.cancel();
+    receiveTimer = Timer(receiveTimeout, () {
+      responseSink.addError(
+        DioException.receiveTimeout(
+          timeout: receiveTimeout,
+          requestOptions: options,
+        ),
+      );
+      response.close();
+      responseSink.close();
+      responseSubscription.cancel();
+      stopWatchReceiveTimeout();
+    });
+  }
+
+  responseSubscription = source.listen(
+    (data) {
+      watchReceiveTimeout();
+      // Always true if the receive timeout was not set.
+      if (receiveStopwatch.elapsed <= receiveTimeout) {
+        responseSink.add(data);
+        options.onReceiveProgress?.call(
+          receivedLength += data.length,
+          totalLength,
+        );
+      }
+    },
+    onError: (error, stackTrace) {
+      stopWatchReceiveTimeout();
+      responseSink.addError(error, stackTrace);
+      responseSink.close();
+    },
+    onDone: () {
+      stopWatchReceiveTimeout();
+      responseSubscription.cancel();
+      responseSink.close();
+    },
+    cancelOnError: true,
+  );
+
+  options.cancelToken?.whenCancel.whenComplete(() {
+    /// Close the response stream upon a cancellation.
+    responseSubscription.cancel();
+    response.close();
+    if (!responseSink.isClosed) {
+      responseSink.addError(options.cancelToken!.cancelError!);
+      responseSink.close();
+    }
+  });
+  return responseSink.stream;
+}
diff --git a/dio/lib/src/transformers/sync_transformer.dart b/dio/lib/src/transformers/sync_transformer.dart
index 88827212d..e33fc6e6c 100644
--- a/dio/lib/src/transformers/sync_transformer.dart
+++ b/dio/lib/src/transformers/sync_transformer.dart
@@ -62,45 +62,8 @@ class SyncTransformer extends Transformer {
       return responseBody;
     }
 
-    final int totalLength;
-    if (options.onReceiveProgress != null) {
-      totalLength = int.parse(
-        responseBody.headers[Headers.contentLengthHeader]?.first ?? '-1',
-      );
-    } else {
-      totalLength = 0;
-    }
-
-    final streamCompleter = Completer<void>();
-    int finalLength = 0;
-    // Keep references to the data chunks and concatenate them later.
-    final chunks = <Uint8List>[];
-    final subscription = responseBody.stream.listen(
-      (Uint8List chunk) {
-        finalLength += chunk.length;
-        chunks.add(chunk);
-        options.onReceiveProgress?.call(finalLength, totalLength);
-      },
-      onError: (Object error, StackTrace stackTrace) {
-        streamCompleter.completeError(error, stackTrace);
-      },
-      onDone: () {
-        streamCompleter.complete();
-      },
-      cancelOnError: true,
-    );
-    options.cancelToken?.whenCancel.then((_) {
-      return subscription.cancel();
-    });
-    await streamCompleter.future;
-
-    // Copy all chunks into the final bytes.
-    final responseBytes = Uint8List(finalLength);
-    int chunkOffset = 0;
-    for (final chunk in chunks) {
-      responseBytes.setAll(chunkOffset, chunk);
-      chunkOffset += chunk.length;
-    }
+    final chunks = await responseBody.stream.toList();
+    final responseBytes = Uint8List.fromList(chunks.expand((c) => c).toList());
 
     // Return the finalized bytes if the response type is bytes.
     if (responseType == ResponseType.bytes) {
diff --git a/example/lib/extend_dio.dart b/example/lib/extend_dio.dart
index 1e2e2020b..1918cbea3 100644
--- a/example/lib/extend_dio.dart
+++ b/example/lib/extend_dio.dart
@@ -2,7 +2,7 @@ import 'package:dio/dio.dart';
 import 'package:dio/io.dart';
 
 class HttpService extends DioForNative {
-  HttpService([BaseOptions? baseOptions]) : super(baseOptions) {
+  HttpService([super.baseOptions]) {
     options
       ..baseUrl = 'https://httpbin.org/'
       ..contentType = Headers.jsonContentType;
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 99aee5c6a..3444ece29 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -4,7 +4,7 @@ version: 0.0.1
 publish_to: "none"
 
 environment:
-  sdk: ">=2.15.0 <4.0.0"
+  sdk: ">=3.0.0 <4.0.0"
 
 dependencies:
   cookie_jar:
diff --git a/plugins/http2_adapter/CHANGELOG.md b/plugins/http2_adapter/CHANGELOG.md
index 5971d9fe9..a65d22591 100644
--- a/plugins/http2_adapter/CHANGELOG.md
+++ b/plugins/http2_adapter/CHANGELOG.md
@@ -2,7 +2,9 @@
 
 ## Unreleased
 
-*None.*
+- Fix cancellation for streamed responses and downloads.
+- Fix progress for streamed responses and downloads.
+- Bump minimum Dart SDK to 3.0.0 as required by the `http2` package.
 
 ## 2.4.0
 
diff --git a/plugins/http2_adapter/lib/src/http2_adapter.dart b/plugins/http2_adapter/lib/src/http2_adapter.dart
index fbfa3d03f..8b1a213c7 100644
--- a/plugins/http2_adapter/lib/src/http2_adapter.dart
+++ b/plugins/http2_adapter/lib/src/http2_adapter.dart
@@ -1,4 +1,3 @@
-// ignore_for_file: void_checks
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
@@ -121,37 +120,6 @@ class Http2Adapter implements HttpClientAdapter {
     bool needResponse = false;
 
     final receiveTimeout = options.receiveTimeout ?? Duration.zero;
-    final receiveStopwatch = Stopwatch();
-    Timer? receiveTimer;
-
-    void stopWatchReceiveTimeout() {
-      receiveTimer?.cancel();
-      receiveTimer = null;
-      receiveStopwatch.stop();
-    }
-
-    void watchReceiveTimeout() {
-      if (receiveTimeout <= Duration.zero) {
-        return;
-      }
-      receiveStopwatch.reset();
-      if (!receiveStopwatch.isRunning) {
-        receiveStopwatch.start();
-      }
-      receiveTimer?.cancel();
-      receiveTimer = Timer(receiveTimeout, () {
-        responseSink.addError(
-          DioException.receiveTimeout(
-            timeout: receiveTimeout,
-            requestOptions: options,
-          ),
-        );
-        responseSink.close();
-        responseSubscription.cancel();
-        stream.terminate();
-        stopWatchReceiveTimeout();
-      });
-    }
 
     late int statusCode;
     responseSubscription = stream.incomingMessages.listen(
@@ -175,14 +143,12 @@ class Http2Adapter implements HttpClientAdapter {
           }
         } else if (message is DataStreamMessage) {
           if (needResponse) {
-            watchReceiveTimeout();
             responseSink.add(
               message.bytes is Uint8List
                   ? message.bytes as Uint8List
                   : Uint8List.fromList(message.bytes),
             );
           } else {
-            stopWatchReceiveTimeout();
             responseSubscription.cancel().whenComplete(() {
               stream.terminate();
               responseSink.close();
@@ -200,12 +166,10 @@ class Http2Adapter implements HttpClientAdapter {
         } else {
           responseSink.addError(error, stackTrace);
         }
-        stopWatchReceiveTimeout();
         responseSubscription.cancel();
         responseSink.close();
       },
       onDone: () {
-        stopWatchReceiveTimeout();
         responseSubscription.cancel();
         responseSink.close();
       },
@@ -248,6 +212,11 @@ class Http2Adapter implements HttpClientAdapter {
       headers: responseHeaders.map,
       redirects: redirects,
       isRedirect: redirects.isNotEmpty,
+      onClose: () {
+        responseSubscription.cancel();
+        responseSink.close();
+        stream.terminate();
+      },
     );
   }
 
diff --git a/plugins/http2_adapter/pubspec.yaml b/plugins/http2_adapter/pubspec.yaml
index 08d460036..88788cffc 100644
--- a/plugins/http2_adapter/pubspec.yaml
+++ b/plugins/http2_adapter/pubspec.yaml
@@ -12,13 +12,14 @@ repository: https://github.com/cfug/dio/blob/main/plugins/http2_adapter
 issue_tracker: https://github.com/cfug/dio/issues
 
 environment:
-  sdk: ">=2.15.0 <4.0.0"
+  sdk: ">=3.0.0 <4.0.0"
 
 dependencies:
-  http2: ^2.0.0
+  http2: ^2.1.0
   dio: ^5.2.0
 
 dev_dependencies:
   crypto: ^3.0.2
   lints: any
   test: ^1.16.4
+  path: ^1.8.0


Test patch:
diff --git a/dio/test/cancel_token_test.dart b/dio/test/cancel_token_test.dart
index c6dc0da8c..1ad020958 100644
--- a/dio/test/cancel_token_test.dart
+++ b/dio/test/cancel_token_test.dart
@@ -4,6 +4,7 @@ import 'package:mockito/mockito.dart';
 import 'package:test/test.dart';
 
 import 'mock/http_mock.mocks.dart';
+import 'utils.dart';
 
 void main() {
   group(CancelToken, () {
@@ -29,6 +30,35 @@ void main() {
       CancelToken().cancel();
     });
 
+    test(
+      'cancels streamed responses',
+      () async {
+        final dio = Dio()..options.baseUrl = 'https://httpbun.com/';
+
+        final cancelToken = CancelToken();
+
+        final response = await dio.get(
+          'bytes/${1024 * 1024 * 100}',
+          options: Options(responseType: ResponseType.stream),
+          cancelToken: cancelToken,
+          onReceiveProgress: (c, t) {
+            if (c > 5000) {
+              cancelToken.cancel();
+            }
+          },
+        );
+
+        await expectLater(
+          (response.data as ResponseBody).stream.last,
+          throwsDioException(
+            DioExceptionType.cancel,
+            stackTraceContains: 'test/cancel_token_test.dart',
+          ),
+        );
+      },
+      testOn: 'vm',
+    );
+
     test('cancels multiple requests', () async {
       final client = MockHttpClient();
       final token = CancelToken();
diff --git a/dio/test/download_test.dart b/dio/test/download_test.dart
index 762712a58..135f2133d 100644
--- a/dio/test/download_test.dart
+++ b/dio/test/download_test.dart
@@ -35,8 +35,21 @@ void main() {
     final dio = Dio()..options.baseUrl = serverUrl.toString();
     await dio.download('/download', savePath);
 
+    int? total;
+    int? count;
+    await dio.download(
+      '/download',
+      savePath,
+      onReceiveProgress: (c, t) {
+        total = t;
+        count = c;
+      },
+    );
+
     final f = File(savePath);
     expect(f.readAsStringSync(), equals('I am a text file'));
+    expect(count, f.readAsBytesSync().length);
+    expect(count, total);
   });
 
   test('download2', () async {
@@ -142,21 +155,52 @@ void main() {
     final cancelToken = CancelToken();
     final dio = Dio()..options.baseUrl = serverUrl.toString();
     await expectLater(
-      dio
-          .download(
-            '/download',
-            savePath,
-            deleteOnError: true,
-            cancelToken: cancelToken,
-            onReceiveProgress: (count, total) => cancelToken.cancel(),
-          )
-          .catchError((e) => throw (e as DioException).type),
-      throwsA(DioExceptionType.cancel),
+      dio.download(
+        '/download',
+        savePath,
+        deleteOnError: true,
+        cancelToken: cancelToken,
+        onReceiveProgress: (count, total) => cancelToken.cancel(),
+      ),
+      throwsDioException(
+        DioExceptionType.cancel,
+        stackTraceContains: 'test/download_test.dart',
+      ),
     );
     await Future.delayed(const Duration(milliseconds: 100));
     expect(f.existsSync(), isFalse);
   });
 
+  test('cancel download mid stream', () async {
+    const savePath = 'test/download/_test.md';
+    final f = File(savePath)..createSync(recursive: true);
+    expect(f.existsSync(), isTrue);
+
+    final cancelToken = CancelToken();
+    final dio = Dio()..options.baseUrl = 'https://httpbun.com';
+
+    await expectLater(
+      dio.download(
+        '/bytes/10000',
+        savePath,
+        cancelToken: cancelToken,
+        deleteOnError: true,
+        onReceiveProgress: (c, t) {
+          if (c > 5000) {
+            cancelToken.cancel();
+          }
+        },
+      ),
+      throwsDioException(
+        DioExceptionType.cancel,
+        stackTraceContains: 'test/download_test.dart',
+      ),
+    );
+
+    await Future.delayed(const Duration(milliseconds: 100));
+    expect(f.existsSync(), isFalse);
+  });
+
   test('`savePath` types', () async {
     final testPath = p.join(tmp.path, 'savePath');
 
diff --git a/dio/test/request_integration_test.dart b/dio/test/request_integration_test.dart
index e95b409dd..7baca487c 100644
--- a/dio/test/request_integration_test.dart
+++ b/dio/test/request_integration_test.dart
@@ -34,7 +34,6 @@ void main() {
         expect(response.data['args'], {'id': '12', 'name': 'wendu'});
       });
 
-      // TODO This is not supported on web, should we warn?
       test('GET with content', () async {
         final response = await dio.get(
           '/anything',
diff --git a/dio/test/response/response_stream_test.dart b/dio/test/response/response_stream_test.dart
new file mode 100644
index 000000000..e3b3e03b0
--- /dev/null
+++ b/dio/test/response/response_stream_test.dart
@@ -0,0 +1,238 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:dio/dio.dart';
+import 'package:dio/src/response/response_stream_handler.dart';
+import 'package:test/test.dart';
+
+import '../utils.dart';
+
+void main() {
+  group(handleResponseStream, () {
+    late StreamController<Uint8List> source;
+
+    setUp(() {
+      source = StreamController<Uint8List>();
+    });
+
+    test('completes', () async {
+      final stream = handleResponseStream(
+        RequestOptions(),
+        ResponseBody(
+          source.stream,
+          200,
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          Uint8List.fromList([1, 2]),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+      source.add(Uint8List.fromList([1, 2]));
+      source.close();
+    });
+
+    test('unsubscribes from source on cancel', () async {
+      final cancelToken = CancelToken();
+      final stream = handleResponseStream(
+        RequestOptions(
+          cancelToken: cancelToken,
+        ),
+        ResponseBody(
+          source.stream,
+          200,
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          emitsError(matchesDioException(
+            DioExceptionType.cancel,
+            stackTraceContains: 'test/response/response_stream_test.dart',
+          )),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+
+      expect(source.hasListener, isTrue);
+      cancelToken.cancel();
+
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(source.hasListener, isFalse);
+      });
+    });
+
+    test('sends progress with total', () async {
+      int count = 0;
+      int total = 0;
+
+      final stream = handleResponseStream(
+        RequestOptions(
+          onReceiveProgress: (c, t) {
+            count = c;
+            total = t;
+          },
+        ),
+        ResponseBody(
+          source.stream,
+          200,
+          headers: {
+            Headers.contentLengthHeader: ['6'],
+          },
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          Uint8List.fromList([1, 2]),
+          Uint8List.fromList([3, 4, 5]),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 1);
+        expect(total, 6);
+      });
+
+      source.add(Uint8List.fromList([1, 2]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 3);
+        expect(total, 6);
+      });
+
+      source.add(Uint8List.fromList([3, 4, 5]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 6);
+        expect(total, 6);
+      });
+
+      source.close();
+    });
+
+    test('sends progress without total', () async {
+      int count = 0;
+      int total = 0;
+
+      final stream = handleResponseStream(
+        RequestOptions(
+          onReceiveProgress: (c, t) {
+            count = c;
+            total = t;
+          },
+        ),
+        ResponseBody(
+          source.stream,
+          200,
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          Uint8List.fromList([1, 2]),
+          Uint8List.fromList([3, 4, 5]),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 1);
+        expect(total, -1);
+      });
+
+      source.add(Uint8List.fromList([1, 2]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 3);
+        expect(total, -1);
+      });
+
+      source.add(Uint8List.fromList([3, 4, 5]));
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(count, 6);
+        expect(total, -1);
+      });
+
+      source.close();
+    });
+
+    test('emits error on source error', () async {
+      final stream = handleResponseStream(
+        RequestOptions(),
+        ResponseBody(
+          source.stream,
+          200,
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          emitsError(isA<FormatException>()),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+      source.addError(FormatException());
+      source.close();
+
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(source.hasListener, isFalse);
+      });
+    });
+
+    test('emits error on receiveTimeout', () async {
+      final stream = handleResponseStream(
+        RequestOptions(
+          receiveTimeout: Duration(milliseconds: 100),
+        ),
+        ResponseBody(
+          source.stream,
+          200,
+        ),
+      );
+
+      expectLater(
+        stream,
+        emitsInOrder([
+          Uint8List.fromList([0]),
+          Uint8List.fromList([1]),
+          emitsError(matchesDioException(
+            DioExceptionType.receiveTimeout,
+            stackTraceContains: 'test/response/response_stream_test.dart',
+          )),
+          emitsDone,
+        ]),
+      );
+
+      source.add(Uint8List.fromList([0]));
+      await Future.delayed(Duration(milliseconds: 90), () {
+        source.add(Uint8List.fromList([1]));
+      });
+      await Future.delayed(Duration(milliseconds: 110), () {
+        source.add(Uint8List.fromList([2]));
+      });
+
+      await Future.delayed(Duration(milliseconds: 100), () {
+        expect(source.hasListener, isFalse);
+      });
+    });
+  });
+}
diff --git a/dio/test/utils.dart b/dio/test/utils.dart
index fb4c0669e..9dc4003cc 100644
--- a/dio/test/utils.dart
+++ b/dio/test/utils.dart
@@ -175,6 +175,35 @@ final Matcher throwsDioExceptionConnectionError = throwsA(
   ]),
 );
 
+/// A matcher for functions that throw [DioException] of a specified type,
+/// with an optional matcher for the stackTrace containing the specified text.
+Matcher throwsDioException(
+  DioExceptionType type, {
+  String? stackTraceContains,
+  Object? matcher,
+}) =>
+    throwsA(
+      matchesDioException(
+        type,
+        stackTraceContains: stackTraceContains,
+        matcher: matcher,
+      ),
+    );
+
+Matcher matchesDioException(
+  DioExceptionType type, {
+  String? stackTraceContains,
+  Object? matcher,
+}) =>
+    allOf([
+      isA<DioException>(),
+      (DioException e) => e.type == type,
+      if (stackTraceContains != null)
+        (DioException e) =>
+            e.stackTrace.toString().contains(stackTraceContains),
+      if (matcher != null) matcher,
+    ]);
+
 /// A stream of chunks of bytes representing a single piece of data.
 class ByteStream extends StreamView<List<int>> {
   ByteStream(Stream<List<int>> stream) : super(stream);
diff --git a/plugins/http2_adapter/test/request_test.dart b/plugins/http2_adapter/test/request_test.dart
new file mode 100644
index 000000000..b1043004b
--- /dev/null
+++ b/plugins/http2_adapter/test/request_test.dart
@@ -0,0 +1,140 @@
+@TestOn('vm')
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:dio_http2_adapter/dio_http2_adapter.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  late Directory tmp;
+
+  final dio = Dio()
+    ..httpClientAdapter = Http2Adapter(null)
+    ..options.baseUrl = 'https://httpbun.com/';
+
+  setUpAll(() {
+    tmp = Directory.systemTemp.createTempSync('dio_test_');
+    addTearDown(() {
+      tmp.deleteSync(recursive: true);
+    });
+  });
+
+  group('requests >', () {
+    test('download bytes', () async {
+      final path = p.join(tmp.path, 'bytes.txt');
+
+      final size = 10000;
+      int progressEventCount = 0;
+      int count = 0;
+      int total = 0;
+      await dio.download(
+        '/bytes/$size',
+        path,
+        onReceiveProgress: (c, t) {
+          count = c;
+          total = t;
+          progressEventCount++;
+        },
+      );
+
+      final f = File(path);
+      expect(count, f.readAsBytesSync().length);
+      expect(progressEventCount, greaterThan(1));
+      expect(count, total);
+    });
+
+    test('cancels request', () async {
+      final cancelToken = CancelToken();
+
+      Future.delayed(const Duration(milliseconds: 50), () {
+        cancelToken.cancel();
+      });
+
+      await expectLater(
+        dio.get(
+          '/bytes/5000',
+          options: Options(responseType: ResponseType.stream),
+          cancelToken: cancelToken,
+        ),
+        throwsDioException(
+          DioExceptionType.cancel,
+          stackTraceContains: 'test/request_test.dart',
+        ),
+      );
+    });
+
+    test('cancels download', () async {
+      final cancelToken = CancelToken();
+      final path = p.join(tmp.path, 'download.txt');
+
+      Future.delayed(const Duration(milliseconds: 50), () {
+        cancelToken.cancel();
+      });
+
+      await expectLater(
+        dio.download(
+          '/bytes/5000',
+          path,
+          cancelToken: cancelToken,
+        ),
+        throwsDioException(
+          DioExceptionType.cancel,
+          stackTraceContains: 'test/request_test.dart',
+        ),
+      );
+
+      await Future.delayed(const Duration(milliseconds: 250), () {});
+      expect(File(path).existsSync(), false);
+    });
+
+    test('cancels streamed response mid request', () async {
+      final cancelToken = CancelToken();
+      final response = await dio.get(
+        'bytes/${1024 * 1024 * 100}',
+        options: Options(responseType: ResponseType.stream),
+        cancelToken: cancelToken,
+        onReceiveProgress: (c, t) {
+          if (c > 5000) {
+            cancelToken.cancel();
+          }
+        },
+      );
+
+      await expectLater(
+        (response.data as ResponseBody).stream.last,
+        throwsDioException(
+          DioExceptionType.cancel,
+          stackTraceContains: 'test/request_test.dart',
+        ),
+      );
+    });
+
+    test('cancels download mid request', () async {
+      final cancelToken = CancelToken();
+      final path = p.join(tmp.path, 'download_2.txt');
+
+      await expectLater(
+        dio.download(
+          'bytes/${1024 * 1024 * 10}',
+          path,
+          cancelToken: cancelToken,
+          onReceiveProgress: (c, t) {
+            if (c > 5000) {
+              cancelToken.cancel();
+            }
+          },
+        ),
+        throwsDioException(
+          DioExceptionType.cancel,
+          stackTraceContains: 'test/request_test.dart',
+        ),
+      );
+
+      await Future.delayed(const Duration(milliseconds: 250), () {});
+      expect(File(path).existsSync(), false);
+    });
+  });
+}
diff --git a/plugins/http2_adapter/test/utils.dart b/plugins/http2_adapter/test/utils.dart
new file mode 100644
index 000000000..b3ea2355f
--- /dev/null
+++ b/plugins/http2_adapter/test/utils.dart
@@ -0,0 +1,20 @@
+import 'package:dio/dio.dart';
+import 'package:test/test.dart';
+
+/// A matcher for functions that throw [DioException] of a specified type,
+/// with an optional matcher for the stackTrace containing the specified text.
+Matcher throwsDioException(
+  DioExceptionType type, {
+  String? stackTraceContains,
+  Object? matcher,
+}) =>
+    throwsA(
+      allOf([
+        isA<DioException>(),
+        (DioException e) => e.type == type,
+        if (stackTraceContains != null)
+          (DioException e) =>
+              e.stackTrace.toString().contains(stackTraceContains),
+        if (matcher != null) matcher,
+      ]),
+    );