ImageCropper: dispose the state built for the previous image on re-set - #1536
Conversation
…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>
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>
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>
253c5f8 to
cd70ff3
Compare
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>
tombogle
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@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
left a comment
There was a problem hiding this comment.
@tombogle reviewed 1 file and all commit messages.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on imnasnainaec).
tombogle
left a comment
There was a problem hiding this comment.
@tombogle resolved 1 discussion.
Reviewable status:complete! all files reviewed, all discussions resolved (waiting on imnasnainaec).
The
Imagesetter 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 thatPalasoImagetripsDebug.Fail("Not able to delete image temp file")withIOException: the process cannot access the file ... because it is being used by another process.It also assigns
_savedOriginalImagebeforevalue.Image.Savecan throw. If it does, the field is left pointing at the new, empty temp file while the cropper still displays the previous image, soGetCroppedImagecrops 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
Disposeso the three call sites share it.SetImagelikewise commits_originalFormatonly 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 ImageToolboxis green on net8.0-windows and net48: 47 passed, 1 pre-existing skip.🤖 Generated with Claude Code
This change is