From fee0aac12a6f085d10c876d71d761fa49c61d934 Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 2 Dec 2025 17:38:38 +0100 Subject: [PATCH 1/8] Make all callbacks async --- pkgs/cronet_http/lib/src/cronet_client.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index c36ba63517..59a393a163 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -203,6 +203,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( // exception of onFailed's UrlResponseInfo as specified in: // https://source.chromium.org/chromium/chromium/src/+/main:components/cronet/android/api/src/org/chromium/net/UrlRequest.java;l=232 jb.$UrlRequestCallbackProxy$UrlRequestCallbackInterface( + onResponseStarted$async: true, onResponseStarted: (urlRequest, responseInfo) { responseStream = StreamController(onCancel: () { // The user did `response.stream.cancel()`. We can just pretend that @@ -258,6 +259,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( jByteBuffer = JByteBuffer.allocateDirect(_bufferSize); urlRequest?.read(jByteBuffer!); }, + onRedirectReceived$async: true, onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) { if (responseStreamCancelled) return; final responseHeaders = @@ -306,6 +308,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( ClientException('Redirect limit exceeded', request.url)); } }, + onReadCompleted$async: true, onReadCompleted: (urlRequest, responseInfo, byteBuffer) { if (responseStreamCancelled) return; byteBuffer!.flip(); @@ -316,6 +319,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( byteBuffer.clear(); urlRequest!.read(byteBuffer); }, + onSucceeded$async: true, onSucceeded: (urlRequest, responseInfo) { if (responseStreamCancelled) return; responseStreamCancelled = true; @@ -323,6 +327,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( jByteBuffer?.release(); profile?.responseData.close(); }, + onFailed$async: true, onFailed: (urlRequest, responseInfo /* can be null */, cronetException) { if (responseStreamCancelled) return; responseStreamCancelled = true; @@ -344,6 +349,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( } jByteBuffer?.release(); }, + onCanceled$async: true, // Will always be the last callback invoked. // See https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/UrlRequest#cancel() onCanceled: (urlRequest, urlResponseInfo /* can be null */) { From 40f71211d89fd0ebd7a1d80f6abbd72195d51cfc Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 2 Dec 2025 18:16:18 +0100 Subject: [PATCH 2/8] Eagerly release references --- pkgs/cronet_http/lib/src/cronet_client.dart | 476 +++++++++++--------- 1 file changed, 267 insertions(+), 209 deletions(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index 59a393a163..f8bbae880e 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -125,41 +125,53 @@ class CronetEngine { bool? enableQuic, String? storagePath, String? userAgent}) { - final builder = jb.CronetEngine$Builder(Jni.androidApplicationContext); - try { - if (storagePath != null) { - builder.setStoragePath(storagePath.toJString()); - } + return using((arena) { + final builder = jb.CronetEngine$Builder( + Jni.androidApplicationContext..releasedBy(arena)) + ..releasedBy(arena); + + if (storagePath != null) { + builder + .setStoragePath(storagePath.toJString()..releasedBy(arena)) + ?.release(); + } - if (cacheMode == CacheMode.disabled) { - builder.enableHttpCache(0, 0); // HTTP_CACHE_DISABLED, 0 bytes - } else if (cacheMode != null && cacheMaxSize != null) { - builder.enableHttpCache(cacheMode.index, cacheMaxSize); - } + if (cacheMode == CacheMode.disabled) { + builder + .enableHttpCache(0, 0) + ?.release(); // HTTP_CACHE_DISABLED, 0 bytes + } else if (cacheMode != null && cacheMaxSize != null) { + builder.enableHttpCache(cacheMode.index, cacheMaxSize)?.release(); + } - if (enableBrotli != null) { - builder.enableBrotli(enableBrotli); - } + if (enableBrotli != null) { + builder.enableBrotli(enableBrotli)?.release(); + } - if (enableHttp2 != null) { - builder.enableHttp2(enableHttp2); - } + if (enableHttp2 != null) { + builder.enableHttp2(enableHttp2)?.release(); + } - if (enablePublicKeyPinningBypassForLocalTrustAnchors != null) { - builder.enablePublicKeyPinningBypassForLocalTrustAnchors( - enablePublicKeyPinningBypassForLocalTrustAnchors); - } + if (enablePublicKeyPinningBypassForLocalTrustAnchors != null) { + builder + .enablePublicKeyPinningBypassForLocalTrustAnchors( + enablePublicKeyPinningBypassForLocalTrustAnchors) + ?.release(); + } - if (enableQuic != null) { - builder.enableQuic(enableQuic); - } + if (enableQuic != null) { + builder.enableQuic(enableQuic)?.release(); + } - if (userAgent != null) { - builder.setUserAgent(userAgent.toJString()); - } + if (userAgent != null) { + builder + .setUserAgent(userAgent.toJString()..releasedBy(arena)) + ?.release(); + } - return CronetEngine._(builder.build()!); + return CronetEngine._(builder.build()!); + }); } on JniException catch (e) { // TODO: Decode this exception in a better way when // https://github.com/dart-lang/jnigen/issues/239 is fixed. @@ -183,9 +195,13 @@ class CronetEngine { Map _cronetToClientHeaders( JMap?> cronetHeaders) => - cronetHeaders.map((key, value) => MapEntry( - key!.toDartString(releaseOriginal: true).toLowerCase(), - value!.join(','))); + cronetHeaders.map((key, value) { + final entry = MapEntry( + key!.toDartString(releaseOriginal: true).toLowerCase(), + value!.join(',')); + value.release(); + return entry; + }); jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( BaseRequest request, @@ -205,173 +221,208 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( jb.$UrlRequestCallbackProxy$UrlRequestCallbackInterface( onResponseStarted$async: true, onResponseStarted: (urlRequest, responseInfo) { - responseStream = StreamController(onCancel: () { - // The user did `response.stream.cancel()`. We can just pretend that - // the response completed normally. - if (responseStreamCancelled) return; - responseStreamCancelled = true; - urlRequest!.cancel(); - responseStream!.sink.close(); - jByteBuffer?.release(); - profile?.responseData.close(); - }); - final responseHeaders = - _cronetToClientHeaders(responseInfo!.getAllHeaders()!); - int? contentLength; - - switch (responseHeaders['content-length']) { - case final contentLengthHeader? - when !_digitRegex.hasMatch(contentLengthHeader): - responseCompleter.completeError(ClientException( - 'Invalid content-length header [$contentLengthHeader].', - request.url, - )); - urlRequest?.cancel(); - return; - case final contentLengthHeader?: - contentLength = int.parse(contentLengthHeader); - } - responseCompleter.complete(CronetStreamedResponse._( - responseStream!.stream, - responseInfo.getHttpStatusCode(), - responseInfo: responseInfo, - url: Uri.parse( - responseInfo.getUrl()!.toDartString(releaseOriginal: true)), - contentLength: contentLength, - reasonPhrase: responseInfo - .getHttpStatusText()! - .toDartString(releaseOriginal: true), - request: request, - isRedirect: false, - headers: responseHeaders, - )); - - profile?.requestData.close(); - profile?.responseData - ?..contentLength = contentLength - ..headersCommaValues = responseHeaders - ..isRedirect = false - ..reasonPhrase = responseInfo - .getHttpStatusText()! - .toDartString(releaseOriginal: true) - ..startTime = DateTime.now() - ..statusCode = responseInfo.getHttpStatusCode(); - jByteBuffer = JByteBuffer.allocateDirect(_bufferSize); - urlRequest?.read(jByteBuffer!); - }, - onRedirectReceived$async: true, - onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) { - if (responseStreamCancelled) return; - final responseHeaders = - _cronetToClientHeaders(responseInfo!.getAllHeaders()!); - - if (!request.followRedirects) { - urlRequest!.cancel(); + using((arena) { + // `urlRequest` is captured by the `onCancel` callback of the + // `StreamController` below. So it must not be registered to be later + // released here. + // Similarly `responseInfo` is used in `CronetStreamedResponse`. + responseStream = StreamController(onCancel: () { + // The user did `response.stream.cancel()`. We can just pretend that + // the response completed normally. + if (responseStreamCancelled) return; + responseStreamCancelled = true; + urlRequest! + ..cancel() + ..release(); + responseStream!.sink.close(); + jByteBuffer?.release(); + profile?.responseData.close(); + }); + final responseHeaders = _cronetToClientHeaders( + responseInfo!.getAllHeaders()!..releasedBy(arena)); + int? contentLength; + + switch (responseHeaders['content-length']) { + case final contentLengthHeader? + when !_digitRegex.hasMatch(contentLengthHeader): + responseCompleter.completeError(ClientException( + 'Invalid content-length header [$contentLengthHeader].', + request.url, + )); + urlRequest + ?..cancel() + ..release(); + return; + case final contentLengthHeader?: + contentLength = int.parse(contentLengthHeader); + } responseCompleter.complete(CronetStreamedResponse._( - const Stream.empty(), // Cronet provides no body for redirects. - responseInfo.getHttpStatusCode(), - responseInfo: responseInfo, - url: Uri.parse( - responseInfo.getUrl()!.toDartString(releaseOriginal: true)), - contentLength: 0, - reasonPhrase: responseInfo - .getHttpStatusText()! - .toDartString(releaseOriginal: true), - request: request, - isRedirect: true, - headers: _cronetToClientHeaders(responseInfo.getAllHeaders()!))); + responseStream!.stream, + responseInfo.getHttpStatusCode(), + responseInfo: responseInfo, + url: Uri.parse( + responseInfo.getUrl()!.toDartString(releaseOriginal: true)), + contentLength: contentLength, + reasonPhrase: responseInfo + .getHttpStatusText()! + .toDartString(releaseOriginal: true), + request: request, + isRedirect: false, + headers: responseHeaders, + )); + profile?.requestData.close(); profile?.responseData - ?..headersCommaValues = responseHeaders - ..isRedirect = true + ?..contentLength = contentLength + ..headersCommaValues = responseHeaders + ..isRedirect = false ..reasonPhrase = responseInfo .getHttpStatusText()! .toDartString(releaseOriginal: true) ..startTime = DateTime.now() ..statusCode = responseInfo.getHttpStatusCode(); + jByteBuffer = JByteBuffer.allocateDirect(_bufferSize); + urlRequest?.read(jByteBuffer!); + }); + }, + onRedirectReceived$async: true, + onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) { + using((arena) { + urlRequest?.releasedBy(arena); + // `responseInfo` is used in `CronetStreamedResponse` so it must not be + // registered to be released here. + newLocationUrl?.releasedBy(arena); + if (responseStreamCancelled) return; + final responseHeaders = + _cronetToClientHeaders(responseInfo!.getAllHeaders()!); + + if (!request.followRedirects) { + urlRequest!.cancel(); + responseCompleter.complete(CronetStreamedResponse._( + const Stream.empty(), // Cronet provides no body for redirects. + responseInfo.getHttpStatusCode(), + responseInfo: responseInfo, + url: Uri.parse( + responseInfo.getUrl()!.toDartString(releaseOriginal: true)), + contentLength: 0, + reasonPhrase: responseInfo + .getHttpStatusText()! + .toDartString(releaseOriginal: true), + request: request, + isRedirect: true, + headers: _cronetToClientHeaders(responseInfo.getAllHeaders()!))); + + profile?.responseData + ?..headersCommaValues = responseHeaders + ..isRedirect = true + ..reasonPhrase = responseInfo + .getHttpStatusText()! + .toDartString(releaseOriginal: true) + ..startTime = DateTime.now() + ..statusCode = responseInfo.getHttpStatusCode(); - return; - } - ++numRedirects; - if (numRedirects <= request.maxRedirects) { - profile?.responseData.addRedirect(HttpProfileRedirectData( - statusCode: responseInfo.getHttpStatusCode(), - // This method is not correct for status codes 303 to 307. Cronet - // does not seem to have a way to get the method so we'd have to - // calculate it according to the rules in RFC-7231. - method: 'GET', - location: newLocationUrl!.toDartString(releaseOriginal: true))); - urlRequest!.followRedirect(); - } else { - urlRequest!.cancel(); - responseCompleter.completeError( - ClientException('Redirect limit exceeded', request.url)); - } + return; + } + ++numRedirects; + if (numRedirects <= request.maxRedirects) { + profile?.responseData.addRedirect(HttpProfileRedirectData( + statusCode: responseInfo.getHttpStatusCode(), + // This method is not correct for status codes 303 to 307. Cronet + // does not seem to have a way to get the method so we'd have to + // calculate it according to the rules in RFC-7231. + method: 'GET', + location: newLocationUrl!.toDartString(releaseOriginal: true))); + urlRequest!.followRedirect(); + } else { + urlRequest!.cancel(); + responseCompleter.completeError( + ClientException('Redirect limit exceeded', request.url)); + } + }); }, onReadCompleted$async: true, onReadCompleted: (urlRequest, responseInfo, byteBuffer) { - if (responseStreamCancelled) return; - byteBuffer!.flip(); - final data = jByteBuffer!.asUint8List().sublist(0, byteBuffer.remaining); - responseStream!.add(data); - profile?.responseData.bodySink.add(data); - - byteBuffer.clear(); - urlRequest!.read(byteBuffer); + using((arena) { + urlRequest?.releasedBy(arena); + responseInfo?.releasedBy(arena); + byteBuffer?.releasedBy(arena); + if (responseStreamCancelled) return; + byteBuffer!.flip(); + final data = + jByteBuffer!.asUint8List().sublist(0, byteBuffer.remaining); + responseStream!.add(data); + profile?.responseData.bodySink.add(data); + + byteBuffer.clear(); + urlRequest!.read(byteBuffer); + }); }, onSucceeded$async: true, onSucceeded: (urlRequest, responseInfo) { - if (responseStreamCancelled) return; - responseStreamCancelled = true; - responseStream!.sink.close(); - jByteBuffer?.release(); - profile?.responseData.close(); + using((arena) { + urlRequest?.releasedBy(arena); + responseInfo?.releasedBy(arena); + if (responseStreamCancelled) return; + responseStreamCancelled = true; + responseStream!.sink.close(); + jByteBuffer?.release(); + profile?.responseData.close(); + }); }, onFailed$async: true, onFailed: (urlRequest, responseInfo /* can be null */, cronetException) { - if (responseStreamCancelled) return; - responseStreamCancelled = true; - final error = ClientException( - 'Cronet exception: ${cronetException.toString()}', request.url); - if (responseStream == null) { - responseCompleter.completeError(error); - } else { - responseStream!.addError(error); - responseStream!.close(); - } - - if (profile != null) { - if (profile.requestData.endTime == null) { - profile.requestData.closeWithError(error.toString()); + using((arena) { + urlRequest?.releasedBy(arena); + responseInfo?.releasedBy(arena); + cronetException?.releasedBy(arena); + if (responseStreamCancelled) return; + responseStreamCancelled = true; + final error = ClientException( + 'Cronet exception: ${cronetException.toString()}', request.url); + if (responseStream == null) { + responseCompleter.completeError(error); } else { - profile.responseData.closeWithError(error.toString()); + responseStream!.addError(error); + responseStream!.close(); } - } - jByteBuffer?.release(); + + if (profile != null) { + if (profile.requestData.endTime == null) { + profile.requestData.closeWithError(error.toString()); + } else { + profile.responseData.closeWithError(error.toString()); + } + } + jByteBuffer?.release(); + }); }, onCanceled$async: true, // Will always be the last callback invoked. // See https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/UrlRequest#cancel() onCanceled: (urlRequest, urlResponseInfo /* can be null */) { - if (responseStreamCancelled) return; - responseStreamCancelled = true; - final error = RequestAbortedException(request.url); - if (responseStream == null) { - responseCompleter.completeError(error); - } else { - if (!responseStream!.isClosed) { - responseStream!.sink.addError(error); - responseStream!.close(); - } - } - if (profile != null) { - if (profile.requestData.endTime == null) { - profile.requestData.closeWithError(error.toString()); + using((arena) { + urlRequest?.releasedBy(arena); + urlResponseInfo?.releasedBy(arena); + if (responseStreamCancelled) return; + responseStreamCancelled = true; + final error = RequestAbortedException(request.url); + if (responseStream == null) { + responseCompleter.completeError(error); } else { - profile.responseData.closeWithError(error.toString()); + if (!responseStream!.isClosed) { + responseStream!.sink.addError(error); + responseStream!.close(); + } } - } - jByteBuffer?.release(); + if (profile != null) { + if (profile.requestData.endTime == null) { + profile.requestData.closeWithError(error.toString()); + } else { + profile.responseData.closeWithError(error.toString()); + } + } + jByteBuffer?.release(); + }); }, )); } @@ -470,48 +521,55 @@ class CronetClient extends BaseClient { final responseCompleter = Completer(); - final builder = engine._engine.newUrlRequestBuilder( - request.url.toString().toJString(), - jb.UrlRequestCallbackProxy( - _urlRequestCallbacks(request, responseCompleter, profile)), - _executor, - )! - ..setHttpMethod(request.method.toJString()); - - var headers = request.headers; - if (body.isNotEmpty && - !headers.keys.any((h) => h.toLowerCase() == 'content-type')) { - // Cronet requires that requests containing upload data set a - // 'Content-Type' header. - headers = {...headers, 'content-type': 'application/octet-stream'}; - } - headers.forEach((k, v) => builder.addHeader(k.toJString(), v.toJString())); - - if (body.isNotEmpty) { - final JByteBuffer data; - try { - data = body.toJByteBuffer(); - } on JniException catch (e) { - // There are no unit tests for this code. You can verify this behavior - // manually by incrementally increasing the amount of body data in - // `CronetClient.post` until you get this exception. - if (e.message.contains('java.lang.OutOfMemoryError:')) { - throw ClientException( - 'Not enough memory for request body: ${e.message}', request.url); - } - rethrow; + return await using((arena) async { + final jUrl = request.url.toString().toJString()..releasedBy(arena); + final jMethod = request.method.toJString()..releasedBy(arena); + final builder = engine._engine.newUrlRequestBuilder( + jUrl, + jb.UrlRequestCallbackProxy( + _urlRequestCallbacks(request, responseCompleter, profile)), + _executor, + )! + ..releasedBy(arena) + ..setHttpMethod(jMethod); + + var headers = request.headers; + if (body.isNotEmpty && + !headers.keys.any((h) => h.toLowerCase() == 'content-type')) { + // Cronet requires that requests containing upload data set a + // 'Content-Type' header. + headers = {...headers, 'content-type': 'application/octet-stream'}; } + headers.forEach((k, v) => builder.addHeader( + k.toJString()..releasedBy(arena), v.toJString()..releasedBy(arena))); + + if (body.isNotEmpty) { + final JByteBuffer data; + try { + data = body.toJByteBuffer()..releasedBy(arena); + } on JniException catch (e) { + // There are no unit tests for this code. You can verify this behavior + // manually by incrementally increasing the amount of body data in + // `CronetClient.post` until you get this exception. + if (e.message.contains('java.lang.OutOfMemoryError:')) { + throw ClientException( + 'Not enough memory for request body: ${e.message}', + request.url); + } + rethrow; + } - builder.setUploadDataProvider( - jb.UploadDataProviders.create$2(data), _executor); - } + builder.setUploadDataProvider( + jb.UploadDataProviders.create$2(data), _executor); + } - final cronetRequest = builder.build()!; - if (request case Abortable(:final abortTrigger?)) { - unawaited(abortTrigger.whenComplete(cronetRequest.cancel)); - } - cronetRequest.start(); - return responseCompleter.future; + final cronetRequest = builder.build()!..releasedBy(arena); + if (request case Abortable(:final abortTrigger?)) { + unawaited(abortTrigger.whenComplete(cronetRequest.cancel)); + } + cronetRequest.start(); + return responseCompleter.future; + }); } } From 8310abfc7ac25931dd8f61f9a8b5ee9cb910ce13 Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 2 Dec 2025 18:34:35 +0100 Subject: [PATCH 3/8] Do not store a Java object (UrlResponseInfo) externally --- pkgs/cronet_http/lib/src/cronet_client.dart | 42 +++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index f8bbae880e..2f1ee4d1f8 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -28,20 +28,39 @@ class _StreamedResponseWithUrl extends StreamedResponse super.reasonPhrase}); } +class UrlResponseInfo { + final String negotiatedProtocol; + final int receivedByteCount; + final bool wasCached; + + UrlResponseInfo({ + required this.negotiatedProtocol, + required this.receivedByteCount, + required this.wasCached, + }); +} + +extension on jb.UrlResponseInfo { + UrlResponseInfo toDart() => UrlResponseInfo( + negotiatedProtocol: + getNegotiatedProtocol()!.toDartString(releaseOriginal: true), + receivedByteCount: getReceivedByteCount(), + wasCached: wasCached(), + ); +} + /// An HTTP response from the Cronet network stack. /// /// The response body is received asynchronously after the headers have been /// received. class CronetStreamedResponse extends _StreamedResponseWithUrl { - final jb.UrlResponseInfo _responseInfo; + final UrlResponseInfo _responseInfo; /// The protocol (for example `'quic/1+spdy/3'`) negotiated with the server. /// /// It will be the empty string or `'unknown'` if no protocol was negotiated, /// the protocol is not known, or when using plain HTTP or HTTPS. - String get negotiatedProtocol => _responseInfo - .getNegotiatedProtocol()! - .toDartString(releaseOriginal: true); + String get negotiatedProtocol => _responseInfo.negotiatedProtocol; /// The minimum count of bytes received from the network to process this /// request. @@ -51,16 +70,16 @@ class CronetStreamedResponse extends _StreamedResponseWithUrl { /// prior to decompression (for example GZIP) and includes headers and data /// from all redirects. This value may change as more response data is /// received from the network. - int get receivedByteCount => _responseInfo.getReceivedByteCount(); + int get receivedByteCount => _responseInfo.receivedByteCount; /// Whether the response came from the cache. /// /// Is `true` for requests that were revalidated over the network before being /// retrieved from the cache - bool get wasCached => _responseInfo.wasCached(); + bool get wasCached => _responseInfo.wasCached; CronetStreamedResponse._(super.stream, super.statusCode, - {required jb.UrlResponseInfo responseInfo, + {required UrlResponseInfo responseInfo, required super.url, super.contentLength, super.request, @@ -225,7 +244,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( // `urlRequest` is captured by the `onCancel` callback of the // `StreamController` below. So it must not be registered to be later // released here. - // Similarly `responseInfo` is used in `CronetStreamedResponse`. + responseInfo?.releasedBy(arena); responseStream = StreamController(onCancel: () { // The user did `response.stream.cancel()`. We can just pretend that // the response completed normally. @@ -259,7 +278,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( responseCompleter.complete(CronetStreamedResponse._( responseStream!.stream, responseInfo.getHttpStatusCode(), - responseInfo: responseInfo, + responseInfo: responseInfo.toDart(), url: Uri.parse( responseInfo.getUrl()!.toDartString(releaseOriginal: true)), contentLength: contentLength, @@ -289,8 +308,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) { using((arena) { urlRequest?.releasedBy(arena); - // `responseInfo` is used in `CronetStreamedResponse` so it must not be - // registered to be released here. + responseInfo?.releasedBy(arena); newLocationUrl?.releasedBy(arena); if (responseStreamCancelled) return; final responseHeaders = @@ -301,7 +319,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( responseCompleter.complete(CronetStreamedResponse._( const Stream.empty(), // Cronet provides no body for redirects. responseInfo.getHttpStatusCode(), - responseInfo: responseInfo, + responseInfo: responseInfo.toDart(), url: Uri.parse( responseInfo.getUrl()!.toDartString(releaseOriginal: true)), contentLength: 0, From ffea7fae92634c4cc81a1fc5597a017eceb2744e Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 2 Dec 2025 20:26:15 +0100 Subject: [PATCH 4/8] ++ --- pkgs/cronet_http/lib/src/cronet_client.dart | 32 ++++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index 2f1ee4d1f8..f9bbb5dc09 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -268,9 +268,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( 'Invalid content-length header [$contentLengthHeader].', request.url, )); - urlRequest - ?..cancel() - ..release(); + urlRequest!.cancel(); return; case final contentLengthHeader?: contentLength = int.parse(contentLengthHeader); @@ -311,8 +309,8 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( responseInfo?.releasedBy(arena); newLocationUrl?.releasedBy(arena); if (responseStreamCancelled) return; - final responseHeaders = - _cronetToClientHeaders(responseInfo!.getAllHeaders()!); + final responseHeaders = _cronetToClientHeaders( + responseInfo!.getAllHeaders()!..releasedBy(arena)); if (!request.followRedirects) { urlRequest!.cancel(); @@ -328,7 +326,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( .toDartString(releaseOriginal: true), request: request, isRedirect: true, - headers: _cronetToClientHeaders(responseInfo.getAllHeaders()!))); + headers: responseHeaders)); profile?.responseData ?..headersCommaValues = responseHeaders @@ -545,11 +543,12 @@ class CronetClient extends BaseClient { final builder = engine._engine.newUrlRequestBuilder( jUrl, jb.UrlRequestCallbackProxy( - _urlRequestCallbacks(request, responseCompleter, profile)), + _urlRequestCallbacks(request, responseCompleter, profile) + ..releasedBy(arena)), _executor, )! - ..releasedBy(arena) - ..setHttpMethod(jMethod); + ..releasedBy(arena); + builder.setHttpMethod(jMethod)?.release(); var headers = request.headers; if (body.isNotEmpty && @@ -577,16 +576,21 @@ class CronetClient extends BaseClient { rethrow; } - builder.setUploadDataProvider( - jb.UploadDataProviders.create$2(data), _executor); + builder + .setUploadDataProvider( + jb.UploadDataProviders.create$2(data)?..releasedBy(arena), + _executor) + ?.release(); } - final cronetRequest = builder.build()!..releasedBy(arena); + final cronetRequest = builder.build()!; if (request case Abortable(:final abortTrigger?)) { - unawaited(abortTrigger.whenComplete(cronetRequest.cancel)); + unawaited(abortTrigger.whenComplete(() { + if (!cronetRequest.isReleased) cronetRequest.cancel(); + })); } cronetRequest.start(); - return responseCompleter.future; + return responseCompleter.future.whenComplete(cronetRequest.release); }); } } From 52297d04b5fd67cf6607a6c091c89a6ba5a5354c Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 9 Dec 2025 20:16:26 +0100 Subject: [PATCH 5/8] Revert "++" This reverts commit ffea7fae92634c4cc81a1fc5597a017eceb2744e. --- pkgs/cronet_http/lib/src/cronet_client.dart | 32 +++++++++------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index f9bbb5dc09..2f1ee4d1f8 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -268,7 +268,9 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( 'Invalid content-length header [$contentLengthHeader].', request.url, )); - urlRequest!.cancel(); + urlRequest + ?..cancel() + ..release(); return; case final contentLengthHeader?: contentLength = int.parse(contentLengthHeader); @@ -309,8 +311,8 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( responseInfo?.releasedBy(arena); newLocationUrl?.releasedBy(arena); if (responseStreamCancelled) return; - final responseHeaders = _cronetToClientHeaders( - responseInfo!.getAllHeaders()!..releasedBy(arena)); + final responseHeaders = + _cronetToClientHeaders(responseInfo!.getAllHeaders()!); if (!request.followRedirects) { urlRequest!.cancel(); @@ -326,7 +328,7 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( .toDartString(releaseOriginal: true), request: request, isRedirect: true, - headers: responseHeaders)); + headers: _cronetToClientHeaders(responseInfo.getAllHeaders()!))); profile?.responseData ?..headersCommaValues = responseHeaders @@ -543,12 +545,11 @@ class CronetClient extends BaseClient { final builder = engine._engine.newUrlRequestBuilder( jUrl, jb.UrlRequestCallbackProxy( - _urlRequestCallbacks(request, responseCompleter, profile) - ..releasedBy(arena)), + _urlRequestCallbacks(request, responseCompleter, profile)), _executor, )! - ..releasedBy(arena); - builder.setHttpMethod(jMethod)?.release(); + ..releasedBy(arena) + ..setHttpMethod(jMethod); var headers = request.headers; if (body.isNotEmpty && @@ -576,21 +577,16 @@ class CronetClient extends BaseClient { rethrow; } - builder - .setUploadDataProvider( - jb.UploadDataProviders.create$2(data)?..releasedBy(arena), - _executor) - ?.release(); + builder.setUploadDataProvider( + jb.UploadDataProviders.create$2(data), _executor); } - final cronetRequest = builder.build()!; + final cronetRequest = builder.build()!..releasedBy(arena); if (request case Abortable(:final abortTrigger?)) { - unawaited(abortTrigger.whenComplete(() { - if (!cronetRequest.isReleased) cronetRequest.cancel(); - })); + unawaited(abortTrigger.whenComplete(cronetRequest.cancel)); } cronetRequest.start(); - return responseCompleter.future.whenComplete(cronetRequest.release); + return responseCompleter.future; }); } } From 429a364fb0d6abd385e8e5d73dbf1c5b12a0d40f Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 9 Dec 2025 21:45:25 +0100 Subject: [PATCH 6/8] Fix --- pkgs/cronet_http/lib/src/cronet_client.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index 2f1ee4d1f8..afa9512bd9 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -581,7 +581,8 @@ class CronetClient extends BaseClient { jb.UploadDataProviders.create$2(data), _executor); } - final cronetRequest = builder.build()!..releasedBy(arena); + // Not releasing `cronetRequest` as it's used in `whenComplete` callback. + final cronetRequest = builder.build()!; if (request case Abortable(:final abortTrigger?)) { unawaited(abortTrigger.whenComplete(cronetRequest.cancel)); } From 1aa69353c49dc62d2b8fa6f583fe6d3d78539b4e Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Tue, 9 Dec 2025 21:47:10 +0100 Subject: [PATCH 7/8] changelog --- pkgs/cronet_http/CHANGELOG.md | 5 +++++ pkgs/cronet_http/pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkgs/cronet_http/CHANGELOG.md b/pkgs/cronet_http/CHANGELOG.md index b5cba23069..cafe6f1ad4 100644 --- a/pkgs/cronet_http/CHANGELOG.md +++ b/pkgs/cronet_http/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.7.1-wip + +* Made callbacks asynchronous to prevent background errors caused by the + unavailability of the Dart callback. + ## 1.7.0 * Fix a bug where cronet would throw `ClassNotFoundException` in debug mode. diff --git a/pkgs/cronet_http/pubspec.yaml b/pkgs/cronet_http/pubspec.yaml index 5b9c54a041..d88b592401 100644 --- a/pkgs/cronet_http/pubspec.yaml +++ b/pkgs/cronet_http/pubspec.yaml @@ -1,5 +1,5 @@ name: cronet_http -version: 1.7.0 +version: 1.7.1-wip description: >- An Android Flutter plugin that provides access to the Cronet HTTP client. repository: https://github.com/dart-lang/http/tree/master/pkgs/cronet_http From 10d5b41a0218af7dac7715f4d44eaf34322f8fce Mon Sep 17 00:00:00 2001 From: Hossein Yousefi Date: Sun, 14 Dec 2025 21:10:03 +0100 Subject: [PATCH 8/8] address comments --- pkgs/cronet_http/lib/src/cronet_client.dart | 103 +++++++++----------- 1 file changed, 48 insertions(+), 55 deletions(-) diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart index afa9512bd9..b3146ae1be 100644 --- a/pkgs/cronet_http/lib/src/cronet_client.dart +++ b/pkgs/cronet_http/lib/src/cronet_client.dart @@ -19,48 +19,28 @@ class _StreamedResponseWithUrl extends StreamedResponse @override final Uri url; - _StreamedResponseWithUrl(super.stream, super.statusCode, - {required this.url, - super.contentLength, - super.request, - super.headers, - super.isRedirect, - super.reasonPhrase}); -} - -class UrlResponseInfo { - final String negotiatedProtocol; - final int receivedByteCount; - final bool wasCached; - - UrlResponseInfo({ - required this.negotiatedProtocol, - required this.receivedByteCount, - required this.wasCached, + _StreamedResponseWithUrl( + super.stream, + super.statusCode, { + required this.url, + super.contentLength, + super.request, + super.headers, + super.isRedirect, + super.reasonPhrase, }); } -extension on jb.UrlResponseInfo { - UrlResponseInfo toDart() => UrlResponseInfo( - negotiatedProtocol: - getNegotiatedProtocol()!.toDartString(releaseOriginal: true), - receivedByteCount: getReceivedByteCount(), - wasCached: wasCached(), - ); -} - /// An HTTP response from the Cronet network stack. /// /// The response body is received asynchronously after the headers have been /// received. class CronetStreamedResponse extends _StreamedResponseWithUrl { - final UrlResponseInfo _responseInfo; - /// The protocol (for example `'quic/1+spdy/3'`) negotiated with the server. /// /// It will be the empty string or `'unknown'` if no protocol was negotiated, /// the protocol is not known, or when using plain HTTP or HTTPS. - String get negotiatedProtocol => _responseInfo.negotiatedProtocol; + final String negotiatedProtocol; /// The minimum count of bytes received from the network to process this /// request. @@ -70,23 +50,27 @@ class CronetStreamedResponse extends _StreamedResponseWithUrl { /// prior to decompression (for example GZIP) and includes headers and data /// from all redirects. This value may change as more response data is /// received from the network. - int get receivedByteCount => _responseInfo.receivedByteCount; + final int receivedByteCount; /// Whether the response came from the cache. /// /// Is `true` for requests that were revalidated over the network before being /// retrieved from the cache - bool get wasCached => _responseInfo.wasCached; - - CronetStreamedResponse._(super.stream, super.statusCode, - {required UrlResponseInfo responseInfo, - required super.url, - super.contentLength, - super.request, - super.headers, - super.isRedirect, - super.reasonPhrase}) - : _responseInfo = responseInfo; + final bool wasCached; + + CronetStreamedResponse._( + super.stream, + super.statusCode, { + required this.negotiatedProtocol, + required this.receivedByteCount, + required this.wasCached, + required super.url, + super.contentLength, + super.request, + super.headers, + super.isRedirect, + super.reasonPhrase, + }); } /// The type of caching to use when making HTTP requests. @@ -278,7 +262,11 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( responseCompleter.complete(CronetStreamedResponse._( responseStream!.stream, responseInfo.getHttpStatusCode(), - responseInfo: responseInfo.toDart(), + negotiatedProtocol: responseInfo + .getNegotiatedProtocol()! + .toDartString(releaseOriginal: true), + receivedByteCount: responseInfo.getReceivedByteCount(), + wasCached: responseInfo.wasCached(), url: Uri.parse( responseInfo.getUrl()!.toDartString(releaseOriginal: true)), contentLength: contentLength, @@ -317,18 +305,23 @@ jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks( if (!request.followRedirects) { urlRequest!.cancel(); responseCompleter.complete(CronetStreamedResponse._( - const Stream.empty(), // Cronet provides no body for redirects. - responseInfo.getHttpStatusCode(), - responseInfo: responseInfo.toDart(), - url: Uri.parse( - responseInfo.getUrl()!.toDartString(releaseOriginal: true)), - contentLength: 0, - reasonPhrase: responseInfo - .getHttpStatusText()! - .toDartString(releaseOriginal: true), - request: request, - isRedirect: true, - headers: _cronetToClientHeaders(responseInfo.getAllHeaders()!))); + const Stream.empty(), // Cronet provides no body for redirects. + responseInfo.getHttpStatusCode(), + negotiatedProtocol: responseInfo + .getNegotiatedProtocol()! + .toDartString(releaseOriginal: true), + receivedByteCount: responseInfo.getReceivedByteCount(), + wasCached: responseInfo.wasCached(), + url: Uri.parse( + responseInfo.getUrl()!.toDartString(releaseOriginal: true)), + contentLength: 0, + reasonPhrase: responseInfo + .getHttpStatusText()! + .toDartString(releaseOriginal: true), + request: request, + isRedirect: true, + headers: responseHeaders, + )); profile?.responseData ?..headersCommaValues = responseHeaders