Skip to content

[SIL.Windows.Forms] Fix ImageCropper resource leaks and cropping correctness bugs#1528

Draft
imnasnainaec wants to merge 26 commits into
masterfrom
fix/1275-cropper-dispose-stream
Draft

[SIL.Windows.Forms] Fix ImageCropper resource leaks and cropping correctness bugs#1528
imnasnainaec wants to merge 26 commits into
masterfrom
fix/1275-cropper-dispose-stream

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Related to #1275 (does not fully resolve it — the tab-switch crash fix is being deferred to a separate PR). This PR lands a set of related ImageCropper resource, correctness, and robustness fixes found while investigating that crash.

Fixes

  • Image setter leaked the previous temp file and cropping image on re-set. Old resources are now disposed and the fields nulled, so re-setting Image no longer leaks and no longer leaves disposed-but-non-null references.
  • Tall images were never downscaled before cropping. The size check read Width twice; the second condition now correctly checks Height.
  • GetCroppedImage threw NullReferenceException when Image was set directly (not via SetImage). _originalFormat is now initialized in the Image setter, and the redundant assignment in SetImage was removed.
  • JPEG re-encode hardening in GetCroppedImage. The re-encode MemoryStream is reset (Position = 0) before reading, disposed via using, and the cropped bitmap is disposed on the failure path. The result cast was tightened to (Bitmap).
  • CalculateSourceImageArea guarded against a null _croppingImage.
  • ImageCropper now unsubscribes from Application.Idle on dispose. This fixes a handler leak (the handler could otherwise fire on a disposed cropper). Note: this is a related safety fix and is not, on its own, confirmed to resolve the Crash in ImageToolbox switching between Crop and Choose #1275 crash.

Tests

Added ImageCropperTests covering:

  • Double Dispose does not throw.
  • Cropping a PNG returns a usable bitmap that re-encodes cleanly.
  • Cropping a JPEG set directly via the Image property returns a JPEG bitmap that re-encodes cleanly (regression guard for the disposed re-encode stream).
  • Re-setting the image via SetImage updates the format and does not throw.

Devin review: https://app.devin.ai/review/sillsdev/libpalaso/pull/1528

This change is Reviewable

imnasnainaec and others added 24 commits June 30, 2026 15:16
…posal (#1275)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restore from base commit to fix double-encoded UTF-8 (Chinese characters
and curly apostrophe were corrupted by PowerShell Get-Content/Set-Content
mishandling UTF-8-without-BOM), then re-apply our entry using the Edit
tool which preserves encoding correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dispose previous field values before overwriting them so repeated
SetImage calls don't accumulate leaked TempFile handles and GDI+ bitmaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Null _savedOriginalImage and _croppingImage after disposing so a
  mid-setter exception doesn't leave disposed-but-non-null references
  that pass OnPaint's null guard and crash on the next repaint
- Fix pre-existing typo: second condition in image-downscale check was
  Width > 1000 instead of Height > 1000, so tall narrow images were
  never downscaled before cropping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If the Image setter disposes the old cropping image then throws before
assigning the new one (e.g. disk full during Save), _croppingImage is
null while _image is still non-null. A subsequent resize event would
call CalculateSourceImageArea and dereference the null field. Mirrors
the existing null guard already present in OnPaint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents NullReferenceException in GetCroppedImage when the Image
property is set directly rather than via SetImage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GetCroppedImage_JpegImage_SetViaPropertyDirectly_ReturnsJpegBitmap:
  ensures _originalFormat is initialized when Image is set directly
  (bypassing SetImage), guarding against the NullReferenceException fix
- SetImage_CalledTwice_DoesNotThrow: exercises the dispose-and-null
  path on reassignment and verifies the second image's format is used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that the Image setter initializes _originalFormat directly, the
assignment in SetImage is dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop Dispose_DoesNotThrow (calling Dispose twice already covers the
  single-dispose path)
- Drop redundant Assert.Greater(result.Height, 0) in PNG and JPEG tests
  — Width access already forces the full GDI+ decode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dispose_CalledTwice_DoesNotThrow: wrap both calls in Assert.DoesNotThrow
  so both are explicit assertions, matching the test name
- SetImage_CalledTwice_DoesNotThrow: use bare calls for both SetImage
  invocations; the GetCroppedImage assertion below is the real check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SetImage_CalledTwice_DoesNotThrow implied the test was only about
exceptions; the real assertion is that format is correctly updated
after reassignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The reported crash (issue #1275) only surfaces when a cropped JPEG is fed
back into the cropper, whose Image setter re-saves it via value.Image.Save.
The existing JPEG/PNG tests asserted only result.Width/RawFormat, which read
cached metadata and pass even when the backing stream has been disposed, so
they could not catch this regression.

- Add SetImage_ReCropPreviouslyCroppedJpeg_DoesNotThrow, mirroring the real
  Get Image <-> Crop round-trip that crashed.
- Strengthen the JPEG and PNG tests to re-encode the returned bitmap, forcing
  GDI+ to re-read pixel data from the backing store.

Both strengthened JPEG assertions and the new test fail against the previous
(unfixed) product code and pass with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The catch that handles a failed Save/FromStream disposed the MemoryStream
but not the cropped clone Bitmap, which is only disposed on the success
path; a failure leaked the GDI+ bitmap until finalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restore the `using (var stream = new MemoryStream())` around the JPEG
re-encode in GetCroppedImage so the stream is disposed, resolving the CodeQL
"MemoryStream not disposed" warning. Keeps the independent improvements already
present (stream.Position = 0 and the direct (Bitmap) cast); drops the try/catch
that only existed to dispose the stream in the absence of a using.

This does not attempt to fix the #1275 re-crop crash: the returned JPEG bitmap
is again backed by the disposed stream. The two tests that exercise that
scenario (GetCroppedImage_JpegImage_ReturnsJpegBitmapUsableAfterReturn and
SetImage_ReCropPreviouslyCroppedJpeg_DoesNotThrow) are removed since they no
longer pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec self-assigned this Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Palaso Tests

     4 files  ± 0       4 suites  ±0   10m 18s ⏱️ +20s
 5 107 tests + 4   4 872 ✅ +2  234 💤 +1  1 ❌ +1 
16 633 runs  +12  15 909 ✅ +7  721 💤 +2  3 ❌ +3 

For more details on these failures, see this check.

Results for commit 32c9cfb. ± Comparison against base commit 4840de8.

This pull request skips 1 test.
SIL.Tests.IO.FileLocationUtilitiesTests ‑ LocateInProgramFiles_SendValidProgramDeepSearch_ReturnsProgramPath

♻️ This comment has been updated with latest results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec changed the title Fix/1275 cropper dispose stream [SIL.Windows.Forms] Fix ImageCropper crashes and resource leaks (#1275) Jul 10, 2026
…crash fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec changed the title [SIL.Windows.Forms] Fix ImageCropper crashes and resource leaks (#1275) [SIL.Windows.Forms] Fix ImageCropper resource leaks and cropping correctness bugs Jul 10, 2026
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.

1 participant