Version: pdfrx 2.2.24 / pdfrx_engine 0.3.9. All line numbers are from the published pub.dev archives.
The problem
PdfImageExt.createImage() — lib/src/pdfrx_flutter.dart:97-107:
final comp = Completer<Image>();
decodeImageFromPixels(
pixels,
width,
height,
PixelFormat.bgra8888,
(image) => comp.complete(image),
targetWidth: targetWidth,
targetHeight: targetHeight,
);
return comp.future;
The completer is fed only by decodeImageFromPixels' success callback, and dart:ui's decodeImageFromPixels has no error channel at all — its signature takes a single ImageDecoderCallback, and every .then in its internal chain (sky_engine/lib/ui/painting.dart, in decodeImageFromPixels) is written without an onError.
So when the decode fails — ImmutableBuffer.fromUint8List, instantiateCodec, or getNextFrame() completing with an error — three things happen:
callback is never invoked, so comp.complete never runs and comp.future never completes. It hangs, it does not throw.
- The error propagates out of the engine's unawaited
.then chain and escapes as an unhandled async error to the ambient Zone / PlatformDispatcher.onError. It never reaches pdfrx's caller.
- Because it hangs rather than throwing, the
try/catch at lib/src/widgets/pdf_widgets.dart:448-458 is dead code for this failure mode:
try {
final newImage = await pageImage.createImage();
pageImage.dispose();
...
} catch (e) {
developer.log('Error creating image: $e');
pageImage.dispose();
}
The await on line 449 never returns, so neither dispose() runs.
Why that matters: a native leak and a latched-blank widget
PdfImage.dispose() on the pdfium backend is a real malloc.free — pdfrx_engine-0.3.9/lib/src/native/pdfrx_pdfium.dart:1553-1555:
void dispose() {
malloc.free(_buffer);
}
Sitting behind a never-returning await, it is unreachable, so width * height * 4 bytes of malloc'd native memory leak permanently on every failed decode, along with the pending Completer. The same shape appears in the viewer, where finally { img?.dispose(); } (lib/src/widgets/pdf_viewer.dart:1530 and :1602) is likewise inside an async body awaiting the same future.
Separately, _updateImage commits _pageSize before rendering and never rolls it back:
if (pageSize == _pageSize) return; // pdf_widgets.dart:436
_pageSize = pageSize; // :437
After a failure, every later build → _updateImage returns early at :436. _image stays null with no retry and no error state, so the page is permanently blank until _clearCache() happens to run (document/pageNumber/rotation change, dispose, or a resize to different constraints).
How we hit it
A Flutter app rendering newspaper pages on low-memory Android devices. Impeller's DecompressTexture has to do a BGRA→RGBA conversion, which allocates a second full-size bitmap; when that allocation fails the engine produces Exception: Could not allocate intermediate for pixel conversion.
For us that surfaced as ~1,961 Crashlytics events across 149 users, reported as fatal — because the only place the error appears is the global PlatformDispatcher.onError handler, with no type and no context tying it to an image decode. Users saw a permanently blank page. We could not catch it at any pdfrx call site.
Suggested fix
Give the completer an error path. decodeImageFromPixels cannot report errors, but the pieces it wraps can — building on ImmutableBuffer/ImageDescriptor/Codec directly lets the failure be forwarded:
final comp = Completer<Image>();
ImmutableBuffer.fromUint8List(pixels).then((buffer) async {
try {
final descriptor = ImageDescriptor.raw(
buffer, width: width, height: height, pixelFormat: PixelFormat.bgra8888,
);
final codec = await descriptor.instantiateCodec(
targetWidth: targetWidth, targetHeight: targetHeight,
);
final frame = await codec.getNextFrame();
comp.complete(frame.image);
} finally {
buffer.dispose();
}
}).onError(comp.completeError);
return comp.future;
Any shape that guarantees comp always completes would fix all three symptoms at once: the existing catch blocks start working, dispose() becomes reachable so the native buffer is freed, and callers can finally distinguish "this page failed to decode" from an anonymous zone error.
Resetting _pageSize in the failure path of _updateImage would additionally let a failed page recover on the next build instead of latching blank.
Happy to open a PR if the approach looks right to you.
Version: pdfrx 2.2.24 / pdfrx_engine 0.3.9. All line numbers are from the published pub.dev archives.
The problem
PdfImageExt.createImage()—lib/src/pdfrx_flutter.dart:97-107:The completer is fed only by
decodeImageFromPixels' success callback, anddart:ui'sdecodeImageFromPixelshas no error channel at all — its signature takes a singleImageDecoderCallback, and every.thenin its internal chain (sky_engine/lib/ui/painting.dart, indecodeImageFromPixels) is written without anonError.So when the decode fails —
ImmutableBuffer.fromUint8List,instantiateCodec, orgetNextFrame()completing with an error — three things happen:callbackis never invoked, socomp.completenever runs andcomp.futurenever completes. It hangs, it does not throw..thenchain and escapes as an unhandled async error to the ambientZone/PlatformDispatcher.onError. It never reaches pdfrx's caller.try/catchatlib/src/widgets/pdf_widgets.dart:448-458is dead code for this failure mode:The
awaiton line 449 never returns, so neitherdispose()runs.Why that matters: a native leak and a latched-blank widget
PdfImage.dispose()on the pdfium backend is a realmalloc.free—pdfrx_engine-0.3.9/lib/src/native/pdfrx_pdfium.dart:1553-1555:Sitting behind a never-returning
await, it is unreachable, sowidth * height * 4bytes of malloc'd native memory leak permanently on every failed decode, along with the pendingCompleter. The same shape appears in the viewer, wherefinally { img?.dispose(); }(lib/src/widgets/pdf_viewer.dart:1530and:1602) is likewise inside anasyncbody awaiting the same future.Separately,
_updateImagecommits_pageSizebefore rendering and never rolls it back:After a failure, every later
build→_updateImagereturns early at :436._imagestaysnullwith no retry and no error state, so the page is permanently blank until_clearCache()happens to run (document/pageNumber/rotation change, dispose, or a resize to different constraints).How we hit it
A Flutter app rendering newspaper pages on low-memory Android devices. Impeller's
DecompressTexturehas to do a BGRA→RGBA conversion, which allocates a second full-size bitmap; when that allocation fails the engine producesException: Could not allocate intermediate for pixel conversion.For us that surfaced as ~1,961 Crashlytics events across 149 users, reported as fatal — because the only place the error appears is the global
PlatformDispatcher.onErrorhandler, with no type and no context tying it to an image decode. Users saw a permanently blank page. We could not catch it at any pdfrx call site.Suggested fix
Give the completer an error path.
decodeImageFromPixelscannot report errors, but the pieces it wraps can — building onImmutableBuffer/ImageDescriptor/Codecdirectly lets the failure be forwarded:Any shape that guarantees
compalways completes would fix all three symptoms at once: the existingcatchblocks start working,dispose()becomes reachable so the native buffer is freed, and callers can finally distinguish "this page failed to decode" from an anonymous zone error.Resetting
_pageSizein the failure path of_updateImagewould additionally let a failed page recover on the next build instead of latching blank.Happy to open a PR if the approach looks right to you.