## Environment
| | |
|---|---|
| `cronet_http` | 1.8.0 |
| Flutter | ≥ 3.24.0 |
| Platform | Android (Samsung SM-A066B, API 34) |
| Used via | `native_dio_adapter` 1.5.1 → `cronet_http` 1.8.0 |
## Summary
When making HTTP requests with an upload body via `cronet_http` on Android, the
underlying `CronetUrlRequest` Java object is **never released from the Java heap**
after the request completes. A **JNI Global Reference** held by the Chromium Cronet
C++ engine pins the object permanently. Each leaked request retains its
`CronetUploadDataStream` (holding the raw upload bytes), resulting in ~175 KB of
retained memory per request that accumulates indefinitely.
## Steps to Reproduce
Use `cronet_http` (directly or via `native_dio_adapter`) to perform repeated file
uploads in a single session:
```dart
final client = CronetClient.defaultCronetEngine();
for (final imageBytes in images) {
final request = http.Request('PUT', uploadUrl);
request.bodyBytes = imageBytes;
await client.send(request);
}
Monitor with:
adb shell dumpsys meminfo <your.package.name>
# Focus on: Dalvik Heap — HeapSize, HeapAlloc, HeapFree
Observed Behavior
Dalvik Alloc grows linearly at ~848 KB/min with no plateau over 250 uploads:
Baseline: Alloc = 11,675 KB Util = 50%
250 uploads later: Alloc = 53,140 KB Util = 68%
GC runs continuously (Free fixed at 24,576 KB = device heapmaxfree) but cannot
reclaim the leaked objects.
Root Cause — JNI Global Reference not released
Heap dump analyzed with Android Studio Memory Profiler:
748 leaked instances (for ~250 uploads × ~3 requests each):
┌──────────────────────────────────────────────────────────┬───────────┬─────────────────┐
│ Class │ Instances │ Retained (each) │
├──────────────────────────────────────────────────────────┼───────────┼─────────────────┤
│ CronetUrlRequest (org.chromium.net.impl) │ 748 │ ~175 KB │
├──────────────────────────────────────────────────────────┼───────────┼─────────────────┤
│ UrlRequestCallbackProxy (io.flutter.plugins.cronet_http) │ 748 │ — │
├──────────────────────────────────────────────────────────┼───────────┼─────────────────┤
│ CronetMetrics (org.chromium.net.impl) │ 748 │ — │
└──────────────────────────────────────────────────────────┴───────────┴─────────────────┘
Retention chain:
[JNI Global Reference — Cronet C++ native engine] ← GC root, no Java parent
└─ CronetUrlRequest (Shallow: 139 bytes, Retained: ~175 KB)
└─ CronetUploadDataStream (Retained: ~169 KB — raw upload bytes)
With "Show nearest GC root only" in Android Studio Memory Profiler,
CronetUrlRequest appears at Depth=0 with no Java parent. This confirms the
object is pinned by JNI NewGlobalRef() inside the Cronet C++ engine and
DeleteGlobalRef() is never called.
Expected Behavior
When a request completes (success, failure, or cancel) and the response body is fully
consumed, Cronet should release its internal JNI global reference so the
CronetUrlRequest object can be garbage collected.
Hypothesis
For upload requests, one of the following may prevent the native cleanup path:
1. The response body is not fully drained after the upload body is sent — leaving the
request in a non-terminal state in Cronet's internal state machine
2. onSucceeded() / onFailed() / onCanceled() are invoked on the Dart/Java side
but the corresponding native DeleteGlobalRef() is not called
Related Issues
- dart-lang/http #1308 — Cronet resource exhaustion ("Too many Broadcast Receivers
> 1000") during mass downloads — potentially same underlying lifecycle issue
- cfug/dio #1359 — Big file upload causes memory growth with Dio (closed without fix)
Workaround
Avoid CronetClient for upload requests on Android. Fall back to IOClient
(dart:io) which does not exhibit this behavior.
Notes
- iOS equivalent (cupertino_http / NSURLSessionUploadTask) not yet tested
- Heap dump file (.hprof) available upon request
- Leak is 100% reproducible and proportional to upload request count
<!-- Failed to upload "heap_leak_jhat.hprof.zip" -->
Body: