Skip to content

PdfImage.createImage() hangs forever on a decode failure: the Completer has no error path, so the pdfium buffer leaks and the error escapes to the zone #696

Description

@bschmalb-ksta

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:

  1. callback is never invoked, so comp.complete never runs and comp.future never completes. It hangs, it does not throw.
  2. 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.
  3. 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.freepdfrx_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions