Skip to content

Fix ImageCropper crash when switching Crop/Choose tabs#1521

Draft
imnasnainaec wants to merge 23 commits into
masterfrom
fix/1275-imagecropper-crash
Draft

Fix ImageCropper crash when switching Crop/Choose tabs#1521
imnasnainaec wants to merge 23 commits into
masterfrom
fix/1275-imagecropper-crash

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes several resource-management bugs in ImageCropper, most of which could cause crashes in the Image Toolbox: an Application.Idle subscription leak that fired on a disposed object when switching tabs; a prematurely disposed MemoryStream that broke JPEG bitmaps after GetCroppedImage returned; and multiple gaps in the Image setter where old resources were leaked or left in a state that could crash a subsequent repaint or resize. Also fixes two other bugs found while reviewing the same code.

Details

  • Fix Application.Idle subscription leak in ImageCropper.Dispose() — handler was never unsubscribed, firing on a disposed object when the user switched tabs; unsubscription added before the try/catch so it cannot be skipped
  • Fix GetCroppedImage() returning a JPEG-format bitmap backed by a prematurely disposed MemoryStream — GDI+ bitmaps hold a lazy reference to the source stream; using removed so the stream outlives the returned Bitmap
  • Replace as Bitmap with a direct cast so an unexpected non-Bitmap from Image.FromStream throws immediately rather than silently propagating null
  • Fix Image setter leaking previous _savedOriginalImage (TempFile) and _croppingImage on re-set; fields are nulled after disposal so a mid-setter failure does not leave disposed-but-non-null references that pass OnPaint's null guard and crash on repaint
  • Fix CalculateSourceImageArea not guarding against null _croppingImage, which could crash a resize event if the setter threw after disposing the old image
  • Fix pre-existing typo: downscale condition checked Width > 1000 twice instead of Width || Height, so tall narrow images were never downscaled before cropping
  • Fix Image setter not initializing _originalFormat, which caused a NullReferenceException in GetCroppedImage when the property was set directly rather than via SetImage

Test plan

  • Manually open ImageToolbox and switch between Crop and Choose tabs — should not crash

Fixes #1275

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


This change is Reviewable

…posal (#1275)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@imnasnainaec imnasnainaec changed the title Fix ImageCropper crash when switching Crop/Choose tabs (#1275) Fix ImageCropper crash when switching Crop/Choose tabs Jun 30, 2026
@imnasnainaec imnasnainaec self-assigned this Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Palaso Tests

     4 files  ± 0       4 suites  ±0   10m 33s ⏱️ +35s
 5 109 tests + 6   4 875 ✅ + 5  234 💤 +1  0 ❌ ±0 
16 639 runs  +18  15 918 ✅ +16  721 💤 +2  0 ❌ ±0 

Results for commit 6843326. ± 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.

Comment thread SIL.Windows.Forms/ImageToolbox/Cropping/ImageCropper.cs Fixed
Comment thread SIL.Windows.Forms/ImageToolbox/Cropping/ImageCropper.cs Fixed
imnasnainaec and others added 16 commits June 30, 2026 16:11
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>
@imnasnainaec imnasnainaec marked this pull request as ready for review July 1, 2026 17:13
@imnasnainaec imnasnainaec requested a review from mark-sil July 1, 2026 17:13
@imnasnainaec imnasnainaec marked this pull request as draft July 8, 2026 21:37
imnasnainaec and others added 2 commits July 8, 2026 17:56
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>
@imnasnainaec imnasnainaec marked this pull request as ready for review July 9, 2026 12:56
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>
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.

Crash in ImageToolbox switching between Crop and Choose

2 participants