Skip to content

ImageCropper: dispose the state built for the previous image on re-set - #1536

Merged
imnasnainaec merged 10 commits into
masterfrom
fix/1275-cropper-setter-leak
Aug 27, 2026
Merged

ImageCropper: dispose the state built for the previous image on re-set#1536
imnasnainaec merged 10 commits into
masterfrom
fix/1275-cropper-setter-leak

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The Image setter holds two things for the image it is cropping: a temp file with the original at full resolution, and a possibly-downscaled bitmap to display. It mishandles both when given a new image.

It overwrites the two fields without disposing what was there, leaking a temp file and a bitmap on every re-set — the normal path, since re-entering the Crop tab re-sets Image. The leaked bitmap is a clone of a file-backed image, so it also holds a lock on the previous source file: disposing that PalasoImage trips Debug.Fail("Not able to delete image temp file") with IOException: the process cannot access the file ... because it is being used by another process.

It also assigns _savedOriginalImage before value.Image.Save can throw. If it does, the field is left pointing at the new, empty temp file while the cropper still displays the previous image, so GetCroppedImage crops from the empty file.

The change

Build the temp file and the cropping bitmap into locals and store them only once both are ready — disposing the previous pair at that point, or the partial state if anything throws. The BL-2680-tolerant disposal is factored out of Dispose so the three call sites share it.

SetImage likewise commits _originalFormat only after the setter succeeds, so a failure no longer leaves it on the new image while everything else is on the old.

Tests

Both fail without the change.

  • SetImage_Reassigned_DisposesStateBuiltForPreviousImage — the previous temp file is still on disk after a re-set.
  • Image_NewImageFailsToLoad_LeavesPreviousImageStateIntact — after a failed assignment the cropper still holds the same temp file and bitmap.

--filter ImageToolbox is green on net8.0-windows and net48: 47 passed, 1 pre-existing skip.

🤖 Generated with Claude Code


This change is Reviewable

imnasnainaec and others added 2 commits August 26, 2026 14:05
…1275)

The ImageCropper Image setter overwrote _savedOriginalImage and
_croppingImage without disposing them, so every re-set leaked a temp file
and a bitmap. The leaked clone also kept a lock on the previous source
file, so disposing that PalasoImage could not delete it.

Capture the previous state first and dispose it only once the new state
is fully in place, so a failure while building the new state leaves the
cropper on the image it was already showing rather than half-updated.
The existing body of the setter is unchanged.

Factor the BL-2680-tolerant disposal out of Dispose so both paths share
it, and dispose the bitmap before the temp file since only the latter
can fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The setter still assigned _savedOriginalImage before value.Image.Save
could throw, so a failure left the field pointing at the new, empty temp
file while _croppingImage and _image still held the previous image, and
leaked the previous temp file. GetCroppedImage would then crop from the
empty file even though the cropper was still displaying the old image.

Build both into locals and only store them once the new image is ready,
disposing the partial state if anything throws.

The failure test went through SetImage, which reads image.Image.RawFormat
before the setter is entered, so it never exercised this path and passed
against any implementation. Assign the property directly instead, and
assert the saved-original temp file is unchanged too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Palaso Tests

     4 files  ±0       4 suites  ±0   11m 30s ⏱️ +57s
 5 121 tests +2   4 887 ✅ +2  234 💤 ±0  0 ❌ ±0 
16 675 runs  +6  15 954 ✅ +6  721 💤 ±0  0 ❌ ±0 

Results for commit 0589d8f. ± Comparison against base commit a8bc5a7.

♻️ This comment has been updated with latest results.

Comment thread SIL.Windows.Forms.Tests/ImageToolbox/ImageCropperTests.cs Fixed
Comment thread SIL.Windows.Forms.Tests/ImageToolbox/ImageCropperTests.cs Fixed
imnasnainaec and others added 2 commits August 26, 2026 15:10
CodeQL flagged two useless assignments in the tests, where a local was
assigned only to touch a property getter. Use NUnit constraint syntax so
the value is actually used.

Drop the comment narrating what the setter used to do, and the doc block
on DisposeImageState that only restated its name and enumerated its call
sites. Trim the remaining two to the part that is not visible in the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TempFile.Dispose already ignores an IOException from deleting the file,
and our TempFile has no folder to delete, so that catch could never fire.
UnauthorizedAccessException does still reach us.

Since it can therefore still throw, get every field onto the new image
before disposing what it replaces, so a failed cleanup cannot leave the
cropper showing a disposed image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec self-assigned this Aug 26, 2026
imnasnainaec and others added 2 commits August 26, 2026 17:07
State the ordering discipline once, covering both building the new state
and disposing the old, instead of at each site. The test's disposed
bitmap is already named for what it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SetImage wrote _originalFormat before entering the Image setter, so a
failure left it on the new image while everything else stayed on the old.
GetCroppedImage would then apply the new image's format while cropping
the old original.

Commit it only once the setter has succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec force-pushed the fix/1275-cropper-setter-leak branch from 253c5f8 to cd70ff3 Compare August 26, 2026 21:56
DisposeImageState for the previous state ran before the grips were
rebuilt, so an unexpected throw there would leave the new image in place
with the crop rectangle still sized for the old one. Move it after
Invalidate; a throw now only leaks the old temp file and bitmap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the disposal after the grip rebuild traded a stale-grip state for
a leak if the rebuild throws. A finally gets both: the grips are rebuilt
first, and the previous temp file and bitmap go regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec marked this pull request as ready for review August 27, 2026 13:47
@tombogle
tombogle self-requested a review August 27, 2026 14:31

@tombogle tombogle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tombogle reviewed 3 files and all commit messages, and made 1 comment.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on imnasnainaec and tombogle).


SIL.Windows.Forms.Tests/ImageToolbox/ImageCropperTests.cs line 202 at r2 (raw file):

		}

		private static object GetPrivateField(ImageCropper cropper, string fieldName)

I think this could use the existing ReflectionHelper.GetField rather than recreating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec reviewed all commit messages and made 1 comment.
Reviewable status: 2 of 3 files reviewed, 1 unresolved discussion (waiting on tombogle).


SIL.Windows.Forms.Tests/ImageToolbox/ImageCropperTests.cs line 202 at r2 (raw file):

Previously, tombogle (Tom Bogle) wrote…

I think this could use the existing ReflectionHelper.GetField rather than recreating it.

Thanks! Done.

@tombogle tombogle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tombogle reviewed 1 file and all commit messages.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on imnasnainaec).

@tombogle tombogle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tombogle resolved 1 discussion.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on imnasnainaec).

@imnasnainaec
imnasnainaec enabled auto-merge (squash) August 27, 2026 18:20
@imnasnainaec
imnasnainaec merged commit 40760d4 into master Aug 27, 2026
11 checks passed
@imnasnainaec
imnasnainaec deleted the fix/1275-cropper-setter-leak branch August 27, 2026 18:26
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.

3 participants