Skip to content

fix(createImage): surface decode failures instead of hanging forever - #699

Merged
espresso3389 merged 1 commit into
espresso3389:masterfrom
bschmalb-ksta:fix/create-image-error-path
Aug 24, 2026
Merged

fix(createImage): surface decode failures instead of hanging forever#699
espresso3389 merged 1 commit into
espresso3389:masterfrom
bschmalb-ksta:fix/create-image-error-path

Conversation

@bschmalb-ksta

Copy link
Copy Markdown
Contributor

Fixes #696.

The problem

PdfImageExt.createImage() creates a Completer<Image> and hands decodeImageFromPixels() a success-only callback:

final comp = Completer<Image>();
decodeImageFromPixels(
  pixels, width, height, PixelFormat.bgra8888,
  (image) => comp.complete(image),
  targetWidth: targetWidth, targetHeight: targetHeight,
);
return comp.future;

ImageDecoderCallback has no error parameter, and decodeImageFromPixels chains .then() without a .catchError. So when the engine fails — in practice Could not allocate intermediate for pixel conversion, i.e. a large page on a memory-constrained device — the completer is never completed at all. The caller does not get an exception, it gets a future that never returns.

Three consequences:

  1. The try/catch in PdfPageView._updateImage() cannot fire, because there is nothing to catch. createImage isn't async, so that catch only ever saw a synchronous throw from argument validation.
  2. The pdfium buffer leaks. pageImage.dispose() — which is malloc.free(_buffer) — sits after the never-returning await. Every failed render leaks a full page bitmap.
  3. The engine's error escapes into the zone as an unhandled async error with nothing in the stack pointing at pdfrx, which makes it very hard to attribute. This is how I found it: it showed up as an uncatchable crash in our own app's error reporting with no pdfrx frames.

The fix

Drive the codec directly. This is exactly what decodeImageFromPixels does internally (ImmutableBufferImageDescriptor.rawinstantiateCodecgetNextFrame), except awaited, so a failure propagates to the caller as an exception. try/finally releases the buffer, descriptor and codec on both paths; the SDK's version disposes the buffer and descriptor only in its last .then(), so both leak if the chain rejects earlier.

Behaviour on success is unchanged: same pixel format, same target size logic, same disposal order as the SDK.

With that fixed, the catch in _updateImage() becomes reachable, so two things there needed adjusting:

  • pageImage.dispose() moved into a finally. It was duplicated across the try and the catch, and skipped entirely if createImage() threw.
  • _pageSize is restored on failure. It is committed before the render and guarded by if (pageSize == _pageSize) return;, so a page that failed once would never be re-rendered at that size again. Without this, fixing the hang just turns one permanently-blank page into another. It only restores if a newer call hasn't already claimed _pageSize, so a superseded render can't stomp a live one.

PdfViewer's two createImage() call sites already use finally, so they only benefit from the first change.

Tests

createImage() had no test coverage, so I added some: native-size decode, downscaling to pixelSizeThreshold, the already-under-threshold no-op, and the regression itself — a PdfImage claiming 4000x3000 over a 4x3 buffer. That last one asserts a throw; on master it hangs until the suite times out:

00:00 +3: throws instead of hanging when the buffer cannot be decoded
Shell: [ERROR:flutter/lib/ui/painting/image_decoder_skia.cc(90)] Could not create image from decompressed bytes.
00:00 +3 -1: throws instead of hanging when the buffer cannot be decoded [E]
  Exception: Codec failed to produce an image, possibly due to invalid image data.

  TimeoutException after 0:00:30.000000: Test timed out after 30 seconds.

Note what that shows: the engine did report the failure, as Exception: Codec failed to produce an image, possibly due to invalid image data. — it just arrived as an unhandled error in the zone rather than at the await, which is consequence 3 above. With the fix the same case throws at the call site and the test passes in under a second.

Checks

flutter analyze clean, dart format reports no changes, and the new tests pass. Of the pre-existing tests, pdfium_loading_test.dart and pdf_viewer_test.dart need a real pdfium binary and network access respectively, so I couldn't run them here — happy to adjust if CI flags anything.

No CHANGELOG entry, matching #698. Let me know if you'd prefer one.

PdfImageExt.createImage() built a Completer and passed
decodeImageFromPixels() a success-only callback. That callback has no
error channel, so when the engine fails to decode or - far more often -
fails to allocate the intermediate texture for a large page on a
memory-constrained device, the Completer is never completed at all.

The caller does not get an exception, it gets a future that never
returns. Three things follow from that:

- The `try`/`catch` in PdfPageView._updateImage() cannot fire, because
  there is nothing to catch. It only ever caught a synchronous throw.
- `pageImage.dispose()` sits after the await, so the malloc'd pdfium
  buffer is never freed. Every failed render leaks a full page bitmap.
- The engine's own error still escapes into the zone as an unhandled
  async error with no stack pointing at pdfrx, which is very hard to
  attribute.

Drive the codec directly instead. This is what decodeImageFromPixels()
does internally (ImmutableBuffer -> ImageDescriptor.raw ->
instantiateCodec -> getNextFrame), except awaited, so a failure
propagates to the caller as an exception. try/finally releases the
buffer, descriptor and codec on both paths.

With that fixed, PdfPageView._updateImage()'s catch becomes reachable,
so two things there need adjusting:

- Move `pageImage.dispose()` into a `finally`. It was duplicated across
  the try and the catch, and skipped entirely if createImage() threw
  after the first dispose.
- Restore the previous `_pageSize` on failure. `_pageSize` is committed
  before the render and guarded by an equality check, so a page that
  failed once would never be re-rendered at that size again - the hang
  would just become a permanently blank page. Only restore it if a newer
  call has not already claimed `_pageSize`.

PdfViewer's two createImage() call sites already use `finally`, so they
only benefit from the fix.

Adds tests for createImage(), which had none. The last one claims a
4000x3000 image over a 4x3 buffer and asserts a throw; before this
change it hung until the suite timed out.
@espresso3389
espresso3389 merged commit 7626623 into espresso3389:master Aug 24, 2026
12 checks passed
@bschmalb-ksta

Copy link
Copy Markdown
Contributor Author

@espresso3389 you're a machine! TY for the quick merges ❤️

@espresso3389

Copy link
Copy Markdown
Owner

This fix has been released in pdfrx 2.4.8.

Written by Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants