fix(engine): dispose uncommitted scaled text handles in FormattedText.GetScaledTextCache#2000
fix(engine): dispose uncommitted scaled text handles in FormattedText.GetScaledTextCache#2000yuto-trd wants to merge 5 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough
ChangesException-safe scaled text cache commit
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
Fixes a native-handle leak in Beutl.Engine where density-scaled SKTextBlob / SKPath allocations could become unowned (and never disposed) if FormattedText.GetScaledTextCache throws mid-commit, and adds unit tests to cover the failure/rollback path.
Changes:
- Wrap scaled-cache eviction + insert in
try/catch, disposing newly allocated blob/stroke and rolling back the just-added LRU node on failure. - Add an
internalfault-injection hook to deterministically test the mid-insert failure window. - Add NUnit tests asserting (1) uncommitted handles are disposed, (2) cache remains consistent after failure, and (3) eviction path doesn’t corrupt subsequent inserts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/Beutl.Engine/Media/TextFormatting/FormattedText.cs |
Adds exception-safe commit/rollback for scaled text cache insertion plus a test seam hook. |
tests/Beutl.UnitTests/Engine/FormattedTextScaledCacheLeakTests.cs |
Adds regression coverage for the leak/rollback scenario and scaled-cache eviction behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
In GetScaledTextCache's failure path, (textBlob, strokePath).DisposeAll() could throw and mask the original eviction/commit exception (and skip the second handle if the first threw). Dispose each handle best-effort instead, mirroring RenderNodeProcessor.DisposeBestEffort, so the original failure propagates unchanged and both handles are released. Addresses Copilot review on PR #2000. Claude-Session: https://claude.ai/code/session_017uHhRCTJtRCQiv1iHvmCy3
….GetScaledTextCache GetScaledTextCache allocates a fresh density-scaled SKTextBlob + SKPath via MeasureCore, but nothing owns those native handles until _scaledTextCache.Add succeeds. A throw in the meantime — an evicted entry's Dispose, or an OOM in the LRU LinkedList / Dictionary mutation — left the freshly-allocated blob/stroke unreferenced and undisposed (leak). Wrap the eviction + cache-insert region in try/catch: on failure, dispose the new blob/stroke and roll back the LRU node so the two collections stay consistent. The original exception still propagates. Success path is unchanged. A minimal internal test seam (_scaledTextCacheCommitFaultHook, null in production) lets the new NUnit fixture drive the mid-insert failure deterministically and assert the handles are released and the cache is not poisoned. Refs: Project #9 "AI Review" item "FormattedText.GetScaledTextCache: orphaned textBlob/strokePath leak if cache insert throws" Claude-Session: https://claude.ai/code/session_017uHhRCTJtRCQiv1iHvmCy3
In GetScaledTextCache's failure path, (textBlob, strokePath).DisposeAll() could throw and mask the original eviction/commit exception (and skip the second handle if the first threw). Dispose each handle best-effort instead, mirroring RenderNodeProcessor.DisposeBestEffort, so the original failure propagates unchanged and both handles are released. Addresses Copilot review on PR #2000. Claude-Session: https://claude.ai/code/session_017uHhRCTJtRCQiv1iHvmCy3
ed2f0f7 to
4efcbdc
Compare
…lure The existing GetScaledTextCache_StaysConsistent_AfterCommitFailure test only re-accessed the density after a forced commit failure, which the cache self-heals on the next eviction; removing the LRU-node rollback in the catch block would not fail it. Expose the cache/LRU counts via an internal test seam and assert the LRU list stays in lockstep with the cache dictionary after a failed commit and after eviction caps the cache.
Move the per-density scaled-text cache (dictionary + LRU list, eviction at 8 entries, commit/rollback, best-effort handle disposal) out of FormattedText into a dedicated internal ScaledTextCache. FormattedText injects a MeasureScaledText factory once per instance and delegates the density accessors to it after MeasureAndSetField, so behavior — including the recent handle-leak fix and the LRU/dict lockstep invariant — is preserved. This also removes the two test-only seams from the production type. Relocate the leak/eviction tests to ScaledTextCacheTests, driving the cache directly via a stub factory, and add a FormattedText-level test that a property change reshapes the scaled blob (the cache-clear path the relocation no longer covers through FormattedText).
|
No TODO comments were found. |
Minimum allowed line rate is |
Description
FormattedText.GetScaledTextCacheallocates a density-scaledSKTextBlob+SKPathviaMeasureCore, but those native handles are not owned by anyScaledTextCache(and so not released byClearScaledTextCache/Dispose) until_scaledTextCache.Addsucceeds. If anything throws in the window between the allocation and thatAdd— an evicted entry'sDispose()throwing, or an OOM in the LRULinkedList.AddFirst/Dictionary.Addmutation — the freshly-allocated blob/stroke are left unreferenced and undisposed (a native-handle leak).try/catch. On failure: dispose the new blob/stroke ((textBlob, strokePath).DisposeAll()) and roll back the just-added LRU node so_scaledTextCacheLruand_scaledTextCachestay consistent. The original exception propagates unchanged.internaltest seam_scaledTextCacheCommitFaultHook(null in production) so the new fixture can drive the mid-insert failure deterministically — mirroring the established faulting-component pattern inRenderNodeProcessorExceptionSafetyTests.Affected areas
Beutl.Engine(rendering / scene / track)Beutl.ProjectSystem(project / document persistence)Beutl.Editor,Beutl.Editor.Components,Beutl.Controls)Beutl.Extensibility(plugin abstractions)Beutl.NodeGraph(node editor)Beutl.FFmpegIpc/Beutl.FFmpegWorker(media IPC boundary)Beutl.Api(server API client)Breaking changes
None — behavior-preserving on the success path; only the previously-leaking throw path now cleans up.
Test plan
New fixture
tests/Beutl.UnitTests/Engine/FormattedTextScaledCacheLeakTests.cs(3 tests, all green):GetScaledTextCache_DisposesUncommittedHandlesAndPropagates_WhenCommitThrows— injects a fault at the commit point, asserts the scaledSKTextBlob/SKPathhandles go toIntPtr.Zero(disposed) and the originalInvalidOperationExceptionpropagates.GetScaledTextCache_StaysConsistent_AfterCommitFailure— after a failed insert, a later access at the same density succeeds (LRU/dict not poisoned).GetScaledTextCache_EvictsWithoutCorruption_WhenExceedingMaxEntries— drives the eviction loop (12 > cap of 8 densities) and confirms each fresh density yields a live blob.Verification:
FormattedText*+TextBlockTests): 20 passed / 0 failed.Expected 0, but was 45475791968), confirming the test detects the leak.dotnet format --verify-no-changeson both changed files: clean.Fixed issues / References
https://claude.ai/code/session_017uHhRCTJtRCQiv1iHvmCy3
Summary by CodeRabbit