Skip to content

fix(engine): dispose uncommitted scaled text handles in FormattedText.GetScaledTextCache#2000

Open
yuto-trd wants to merge 5 commits into
mainfrom
yuto-trd/formattedtext-scaledcache-leak
Open

fix(engine): dispose uncommitted scaled text handles in FormattedText.GetScaledTextCache#2000
yuto-trd wants to merge 5 commits into
mainfrom
yuto-trd/formattedtext-scaledcache-leak

Conversation

@yuto-trd

@yuto-trd yuto-trd commented Jun 23, 2026

Copy link
Copy Markdown
Member

Description

FormattedText.GetScaledTextCache allocates a density-scaled SKTextBlob + SKPath via MeasureCore, but those native handles are not owned by any ScaledTextCache (and so not released by ClearScaledTextCache/Dispose) until _scaledTextCache.Add succeeds. If anything throws in the window between the allocation and that Add — an evicted entry's Dispose() throwing, or an OOM in the LRU LinkedList.AddFirst / Dictionary.Add mutation — the freshly-allocated blob/stroke are left unreferenced and undisposed (a native-handle leak).

  • Wrap the eviction loop + cache-insert region in try/catch. On failure: dispose the new blob/stroke ((textBlob, strokePath).DisposeAll()) and roll back the just-added LRU node so _scaledTextCacheLru and _scaledTextCache stay consistent. The original exception propagates unchanged.
  • The success path (cache hit, cache miss + insert, eviction) is behavior-preserving.
  • Added a minimal internal test seam _scaledTextCacheCommitFaultHook (null in production) so the new fixture can drive the mid-insert failure deterministically — mirroring the established faulting-component pattern in RenderNodeProcessorExceptionSafetyTests.

Affected areas

  • Beutl.Engine (rendering / scene / track)
  • Beutl.ProjectSystem (project / document persistence)
  • UI (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)
  • Build / CI / docs only

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 scaled SKTextBlob/SKPath handles go to IntPtr.Zero (disposed) and the original InvalidOperationException propagates.
  • 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:

  • New fixture: 3 passed / 0 failed.
  • Surrounding regression (FormattedText* + TextBlockTests): 20 passed / 0 failed.
  • Negative check: temporarily removing the cleanup line made the fault test fail with a non-zero handle (Expected 0, but was 45475791968), confirming the test detects the leak.
  • dotnet format --verify-no-changes on both changed files: clean.

Fixed issues / References

  • Project board: b-editor/projects/9 ("FormattedText.GetScaledTextCache: orphaned textBlob/strokePath leak if cache insert throws")

https://claude.ai/code/session_017uHhRCTJtRCQiv1iHvmCy3

Summary by CodeRabbit

  • Bug Fixes
    • Improved failure-safe handling for scaled text caching: if a scaled cache commit fails, newly created native text and stroke resources are disposed and the original error is propagated.
    • Strengthened eviction/insertion logic to keep cache and LRU tracking consistent, preventing corruption and ensuring future render requests remain reliable.
  • Tests
    • Added unit tests validating commit-failure disposal, cache/LRU consistency after failures, and correct eviction behavior when exceeding the maximum scaled cache entries.

Copilot AI review requested due to automatic review settings June 23, 2026 17:22
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

FormattedText.GetScaledTextCache is rewritten to be exception-safe: it tracks the new LRU node, invokes an internal nullable test hook, and in a try/catch removes the node and disposes native SKTextBlob/SKPath handles before rethrowing. A new test fixture exercises handle disposal on commit failure, cache consistency after failure, and eviction correctness.

Changes

Exception-safe scaled text cache commit

Layer / File(s) Summary
Test seam field and exception-safe insertion logic
src/Beutl.Engine/Media/TextFormatting/FormattedText.cs
Adds internal Action<SKTextBlob?, SKPath?>? _scaledTextCacheCommitFaultHook test seam and ScaledTextCacheCounts helper. Rewrites the cache-miss path to track the added LRU node, invoke the hook before commit, and roll back (remove node + dispose handles via DisposeBestEffort) on any exception before rethrowing.
NUnit tests: disposal, consistency, and eviction
tests/Beutl.UnitTests/Engine/FormattedTextScaledCacheLeakTests.cs
New FormattedTextScaledCacheLeakTests fixture with Setup and CreateText helpers. Three tests validate: commit-fault disposes both SKTextBlob and SKPath handles; cache is not poisoned after a failed commit; LRU eviction across 12 distinct densities returns valid non-disposed blobs and respects MaxScaledTextCacheEntries cap.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • b-editor/beutl#1937: Introduces density-aware scaled text blob/path caching and LRU behavior in FormattedText.cs; this PR adds exception-safety and rollback to that per-density cache commit path.

Poem

🐇 Hoppity-hop through the cache we go,
Blob and path must never leak, you know!
If commit fails with a terrible throw,
We rollback the node and dispose the flow.
The LRU stays clean, all handles freed —
A tidy cache is a rabbit's creed! 🌸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary change: fixing resource disposal of uncommitted scaled text handles in the FormattedText.GetScaledTextCache method, which is the core issue addressed in this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch yuto-trd/formattedtext-scaledcache-leak

Comment @coderabbitai help to get the list of available commands.

Copilot AI 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.

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 internal fault-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.

Comment thread src/Beutl.Engine/Media/TextFormatting/FormattedText.cs Outdated
yuto-trd added a commit that referenced this pull request Jun 23, 2026
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
yuto-trd added 2 commits June 24, 2026 12:12
….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
@yuto-trd yuto-trd force-pushed the yuto-trd/formattedtext-scaledcache-leak branch from ed2f0f7 to 4efcbdc Compare June 24, 2026 03:13
yuto-trd added 3 commits June 24, 2026 12:37
…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).
@github-actions

Copy link
Copy Markdown
Contributor

No TODO comments were found.

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Branch Rate Complexity Health
Beutl.Api 9% 4% 1171
Beutl.Configuration 41% 22% 350
Beutl.Controls 0% 0% 5513
Beutl.Core 62% 56% 3069
Beutl.Editor 78% 70% 1742
Beutl.Editor.Components 1% 0% 8552
Beutl.Embedding.MediaFoundation 6% 8% 1376
Beutl.Engine 63% 52% 18133
Beutl.Engine.SourceGenerators 59% 44% 540
Beutl.Extensibility 38% 59% 112
Beutl.Extensions.AVFoundation 5% 12% 246
Beutl.Extensions.FFmpeg 6% 7% 853
Beutl.FFmpegIpc 22% 28% 805
Beutl.Language 7% 50% 1348
Beutl.NodeGraph 24% 15% 2474
Beutl.ProjectSystem 58% 43% 1061
Beutl.Threading 100% 90% 137
Beutl.Utilities 94% 87% 358
Summary 35% (43441 / 124662) 31% (10838 / 35494) 47840

Minimum allowed line rate is 0%

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.

2 participants