diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e4be5..7dac05a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed — memory held by paragraphs and pictures an edit removed (2026-09-28) + +The per-paragraph layout cache pruned only once it passed 10,000 entries, and it is keyed by paragraph, so a +paragraph that editing took out of the document stayed reachable — with its text layout (~30 KB), and through +the layout's picture callback any inline picture's bytes — until then. Measured with the new +`LayoutCacheProbe` (`tests/AvaloniaRichEditor.Tests.Render`, `RICHEDITOR_PERF=1`, real Skia): + +- A 2,000-paragraph document rewritten by "select all + type" and a paste, four times over: managed heap + 141 → 210 → 278 → **346 MB**, back to 76 MB only when the cap finally pruned. Now **81 MB flat**. +- 20 inline pictures (40 MB) deleted by editing, undo history aside: **39.5 MB stayed alive**. Now 5.7 MB. + +Every content edit now schedules a prune one second after the last one. The walk costs ~1–2 ms at 2,000 +paragraphs and ~3–5 ms at 8,000 — too much per keystroke, nothing once per pause — and it only drops entries +for paragraphs no longer in the document, so nothing reshapes. Found by measuring what the WinUI port asked +about after bounding its own caches. + +Not changed here: the cache still holds a layout for EVERY paragraph of the document (Measure, Render and +hit-testing share it) — ~20–40 KB each, 6–9× the document model; 8,000 paragraphs ≈ 240–300 MB. Bounding that +needs measurement to stop using the cache (the port measures with throwaway layouts and caches heights). + ### Fixed — found by the WinUIRichEditor port's audit (2026-09-24) The port audited what it had taken in since its last release (these files among it); each defect below diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs index acec113..79331a6 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs @@ -143,6 +143,7 @@ public override void Render(DrawingContext context) bool contentChanged = _textChangedPending; // Flush change events after the edit/caret move that scheduled this paint (off the render stack). RaisePendingChangeEvents(); + if (contentChanged) ScheduleLayoutCachePrune(); // an edit may have dropped paragraphs (see there) // Transparent fill makes the whole control hit-testable (clicks on empty space below the // text must still reach OnPointerPressed). Must cover the full bounds, not a fixed height. context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs index 6eafade..fe57cc0 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs @@ -1612,6 +1612,36 @@ private Avalonia.Media.TextFormatting.TextLayout PreeditAwareLayout(Paragraph p, ? BuildTextLayout(p, width, _caretPosition.Offset, _preeditText) : BuildTextLayout(p, width); + // The 10,000-entry cap above was the only thing that ever pruned, and a Dictionary keyed by paragraph keeps a + // paragraph an edit removed — with its TextLayout, ~30 KB each, and through the layout's picture callback its + // InlineImage's bytes — until then. Measured 2026-09-28 (Tests.Render LayoutCacheProbe, real Skia): a + // 2,000-paragraph document rewritten by "select all + type" and a paste, four times, held 346 MB of managed + // heap, 76 MB after the prune finally ran; 40 MB of inline pictures deleted by editing stayed alive (undo + // history dropped) until the cache was cleared. Now every content edit also schedules a prune, one second + // after the last one: the walk costs ~2 ms at 2,000 paragraphs and ~3.4 ms at 8,000, too much per keystroke + // and nothing once per pause. A pruned entry was, by definition, not in the document, so nothing reshapes. + private DispatcherTimer? _layoutPruneTimer; + + private void ScheduleLayoutCachePrune() + { + if (_layoutPruneTimer == null) + { + _layoutPruneTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _layoutPruneTimer.Tick += (_, _) => RunScheduledLayoutCachePrune(); + } + _layoutPruneTimer.Stop(); + _layoutPruneTimer.Start(); + } + + // The timer's tick; internal so a test can fire it without waiting a second of real time. + internal void RunScheduledLayoutCachePrune() + { + _layoutPruneTimer?.Stop(); + PruneLayoutCaches(); + } + + internal bool LayoutCachePruneScheduled => _layoutPruneTimer?.IsEnabled == true; // test hook + // Drops cache entries for paragraphs/tables no longer in the document (e.g. deleted while editing), // keeping the live ones so nothing reshapes on the next frame. private void PruneLayoutCaches() diff --git a/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs b/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs new file mode 100644 index 0000000..b868aad --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Text; +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using AvaloniaRichEditor.Formatters; +using Xunit; + +namespace AvaloniaRichEditor.Tests.Render; + +// What the per-paragraph TextLayout cache holds — the WinUI peer's question (2026-09-28). There the render +// path caches only what it DRAWS and measurement builds throwaway layouts; here Measure, Render and hit-testing +// share one cache, so it holds every paragraph of the document, pruned only past 10,000 entries — and a +// Dictionary keeps a paragraph an edit removed until then. +// Real Skia (this project), because the no-op backend's shaping would say nothing about a TextLayout's size. +// A measuring harness: skipped unless RICHEDITOR_PERF=1. Also appended to %TEMP%\richeditor_layout_probe.txt. +// $env:RICHEDITOR_PERF=1; dotnet test tests/AvaloniaRichEditor.Tests.Render -c Release --filter LayoutCacheProbe +public class LayoutCacheProbeTests(ITestOutputHelper output) +{ + private static bool Enabled => Environment.GetEnvironmentVariable("RICHEDITOR_PERF") == "1"; + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private const double Mb = 1024 * 1024; + + private void Log(string line) + { + output.WriteLine(line); + System.IO.File.AppendAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "richeditor_layout_probe.txt"), line + "\n"); + } + + // The same paragraph shape as the peer's MemBaseline harness: mixed Hangul/Latin, inline formatting, wraps. + private static string BigHtml(int paras, int from = 0) + { + var sb = new StringBuilder(); + for (int i = from; i < from + paras; i++) + sb.Append("

문단 ").Append(i) + .Append(" — 굵게 기울임 밑줄 취소선 ") + .Append("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod ") + .Append("tempor incididunt ut labore et dolore magna aliqua. ") + .Append("한글과 영문이 충분히 섞인 긴 문장으로 컨트롤 폭에서 자동 줄바꿈을 유발합니다.

"); + return sb.ToString(); + } + + // Measure + arrange + one real render of an 800x600 viewport, then the dispatcher's queue. + private static void Settle(RichEditor ed) + { + ed.Measure(new Size(800, double.PositiveInfinity)); + ed.Arrange(new Rect(0, 0, 800, Math.Max(600, ed.DesiredSize.Height))); + using (var rtb = new RenderTargetBitmap(new PixelSize(800, 600))) rtb.Render(ed); + Dispatcher.UIThread.RunJobs(); + } + + private static long Managed() + { + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + return GC.GetTotalMemory(true); + } + + private static IDictionary Cache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_layoutCache", NP)!.GetValue(ed)!; + private static IDictionary TableCache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_tableLayoutCache", NP)!.GetValue(ed)!; + private static void Invoke(RichEditor ed, string name) => typeof(RichEditor).GetMethod(name, NP, Type.EmptyTypes)!.Invoke(ed, null); + + // Taken in their own frames: a temporary in the test method's frame kept the target reachable (the peer's + // LayoutCostTests met the same trap), and "still alive" would then measure the test, not the editor. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static WeakReference WeakBlock(RichEditor ed, int index) => new(ed.Document!.Blocks[index]); + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static WeakReference WeakPictureBytes(RichEditor ed, int index) + => new(((InlineImage)((Paragraph)ed.Document!.Blocks[index]).Inlines[1]).RawBytes); + + private static long Priv() { var p = Process.GetCurrentProcess(); p.Refresh(); return p.PrivateMemorySize64; } + + [AvaloniaFact] + public void A_WhatTheCacheCosts_ByDocumentSize() + { + if (!Enabled) return; + Log($"=== [upstream] layout cache, {DateTime.Now:yyyy-MM-dd HH:mm}"); + foreach (int n in new[] { 200, 2000, 8000 }) + { + long empty = Managed(); + var doc = HtmlDocumentFormatter.ParseHtml(BigHtml(n)); + long model = Managed() - empty; + var ed = new RichEditor { Document = doc }; + Settle(ed); + long withCache = Managed(); + long privWith = Priv(); + int count = Cache(ed).Count; + Cache(ed).Clear(); + TableCache(ed).Clear(); + long without = Managed(); + long cache = withCache - without; + Settle(ed); // refill, then time the prune walk with nothing to drop — what an edit would pay + var prune = typeof(RichEditor).GetMethod("PruneLayoutCaches", NP)!; + prune.Invoke(ed, null); + var sw = Stopwatch.StartNew(); + for (int k = 0; k < 20; k++) prune.Invoke(ed, null); + double pruneMs = sw.Elapsed.TotalMilliseconds / 20; + Log($" {n,5} paras: model {model / Mb,6:F1} MB | cache holds {count,5} layouts = {cache / Mb,6:F1} MB " + + $"({cache / (double)n / 1024:F1} KB/para, {cache / (double)Math.Max(1, model):F1}x the model) | Priv {privWith / Mb:F0} MB | prune walk {pruneMs:F2} ms"); + GC.KeepAlive(ed); + } + } + + [AvaloniaFact] + public void B_ParagraphsAnEditRemoved_StayInTheCache() + { + if (!Enabled) return; + const int n = 2000, cycles = 6; + var ed = new RichEditor { Document = HtmlDocumentFormatter.ParseHtml(BigHtml(n)) }; + Settle(ed); + // Blocks[1]: select all + type keeps the FIRST paragraph (it takes the "x"), so it is the second that leaves. + var firstWeak = WeakBlock(ed, 1); + Log($" graveyard: {n} paras, each cycle = select all + type 'x' (same document instance) + insert {n} new paras"); + for (int c = 0; c < cycles; c++) + { + Invoke(ed, "SelectAll"); + ed.InsertText("x"); + Settle(ed); + ed.InsertHtml(BigHtml(n, (c + 1) * n)); + Settle(ed); + ed.RunScheduledLayoutCachePrune(); // the pause after the edit: what the 1 s timer does + long m = Managed(); + int live = ed.Document!.Blocks.Count; + Log($" cycle {c}: blocks in doc {live,5} | cache {Cache(ed).Count,5} | managed {m / Mb,6:F1} MB | removed paragraph alive: {firstWeak.IsAlive}"); + } + long before = Managed(); + typeof(RichEditor).GetMethod("PruneLayoutCaches", NP)!.Invoke(ed, null); + long after = Managed(); + Log($" PruneLayoutCaches now: cache {Cache(ed).Count} | frees {(before - after) / Mb:F1} MB | removed paragraph alive: {firstWeak.IsAlive}"); + } + + [AvaloniaFact] + public void C_PicturesAnEditRemoved_AreHeldThroughTheCachedLayout() + { + if (!Enabled) return; + const int pictures = 20, size = 2 * 1024 * 1024; + var doc = new FlowDocument(); + var rnd = new Random(3); + for (int i = 0; i < pictures; i++) + { + var raw = new byte[size]; + rnd.NextBytes(raw); + var img = new InlineImage { Width = 40, Height = 30 }; + img.SetImageData(raw, "image/jpeg"); // undecodable on purpose: the retention is of the BYTES + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = $"picture {i} " }, img } }); + } + var ed = new RichEditor { Document = doc }; + Settle(ed); + var weakBytes = WeakPictureBytes(ed, 10); + long loaded = Managed(); + + Invoke(ed, "SelectAll"); + ed.InsertText("x"); + Settle(ed); + ed.RunScheduledLayoutCachePrune(); // the pause after the edit + typeof(RichEditor).GetField("_undoManager", NP)!.SetValue(ed, Activator.CreateInstance( + typeof(RichEditor).GetField("_undoManager", NP)!.FieldType)); // history is budgeted separately; take it out of the picture + long deleted = Managed(); + bool aliveAfterEdit = weakBytes.IsAlive; + + Cache(ed).Clear(); + long cleared = Managed(); + Log($" pictures: {pictures} x {size / Mb:F0} MB inline, removed by select all + type (undo history dropped)"); + Log($" loaded {loaded / Mb,6:F1} MB | after the edit {deleted / Mb,6:F1} MB (bytes alive: {aliveAfterEdit}) | cache cleared {cleared / Mb,6:F1} MB (bytes alive: {weakBytes.IsAlive})"); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/LayoutCachePruneTests.cs b/tests/AvaloniaRichEditor.Tests/LayoutCachePruneTests.cs new file mode 100644 index 0000000..98b4227 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/LayoutCachePruneTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Paragraphs an edit removed stayed in the layout cache — with their TextLayouts, and through a layout's picture +// callback their InlineImages — until the cache passed 10,000 entries. Measured 2026-09-28 with the Tests.Render +// LayoutCacheProbe: a 2,000-paragraph document rewritten four times held 346 MB (76 MB after the prune), and +// 40 MB of pictures deleted by editing stayed alive. Every content edit now schedules a prune a second after the +// last one; the tests fire the timer's tick directly instead of waiting. +public class LayoutCachePruneTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + private static void Render(RichEditor ed, double w = 400) + { + ed.Measure(new Size(w, double.PositiveInfinity)); + ed.Arrange(new Rect(0, 0, w, ed.DesiredSize.Height)); + using var rtb = new RenderTargetBitmap(new PixelSize((int)w, (int)Math.Max(1, Math.Min(4000, ed.DesiredSize.Height)))); + rtb.Render(ed); + Dispatcher.UIThread.RunJobs(); + } + + private static IDictionary Cache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_layoutCache", NP)!.GetValue(ed)!; + private static void SelectAll(RichEditor ed) => typeof(RichEditor).GetMethod("SelectAll", NP, Type.EmptyTypes)!.Invoke(ed, null); + + private static RichEditor Editor(int paragraphs, bool withPicture = false) + { + var doc = new FlowDocument(); + for (int i = 0; i < paragraphs; i++) + { + var p = new Paragraph { Inlines = { new Run { Text = $"paragraph {i} with enough words to be worth a layout" } } }; + if (withPicture && i == 5) + { + var img = new InlineImage { Width = 20, Height = 20 }; + img.SetImageData(new byte[] { 1, 2, 3, 4 }, "image/png"); + p.Inlines.Add(img); + } + doc.Blocks.Add(p); + } + var ed = new RichEditor { Document = doc }; + Render(ed); + ed.RunScheduledLayoutCachePrune(); // loading is not what these tests are about + return ed; + } + + // Own frames, so no temporary in the test's frame keeps the target reachable. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference WeakBlock(RichEditor ed, int index) => new(ed.Document!.Blocks[index]); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference WeakPicture(RichEditor ed, int index) + => new(((Paragraph)ed.Document!.Blocks[index]).Inlines.OfType().Single()); + + private static void FullGc() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } + + [AvaloniaFact] + public void ParagraphsAnEditRemoved_LeaveTheCache_OnTheScheduledPrune() + { + var ed = Editor(50); + Assert.Equal(50, Cache(ed).Count); + var removed = WeakBlock(ed, 7); // select all + type keeps paragraph 0 (it takes the text); 7 leaves + + SelectAll(ed); + ed.InsertText("x"); + Render(ed); + + Assert.True(ed.LayoutCachePruneScheduled, "an edit's render did not schedule the prune"); + Assert.True(Cache(ed).Count > ed.Document!.Blocks.Count, "the edit left no dead entries — nothing to test"); + + ed.RunScheduledLayoutCachePrune(); + FullGc(); + + Assert.Equal(ed.Document!.Blocks.Count, Cache(ed).Count); + Assert.False(removed.IsAlive, "a paragraph the edit removed is still reachable"); + Assert.False(ed.LayoutCachePruneScheduled); + } + + [AvaloniaFact] + public void APictureAnEditRemoved_IsNoLongerHeldByACachedLayout() + { + var ed = Editor(10, withPicture: true); + var picture = WeakPicture(ed, 5); + + SelectAll(ed); + ed.InsertText("x"); + Render(ed); + ed.RunScheduledLayoutCachePrune(); + FullGc(); + + // The undo snapshot holds a CLONE of the picture (sharing its bytes by design); the original element, + // which the cached layout's picture callback captured, is what must go. + Assert.False(picture.IsAlive, "the removed InlineImage is still reachable"); + } + + [AvaloniaFact] + public void ThePrune_KeepsTheLiveLayouts_SoNothingReshapes() + { + var ed = Editor(20); + var keep = (Paragraph)ed.Document!.Blocks[3]; + var before = Cache(ed)[keep]; + + ed.Document!.Blocks.RemoveAt(10); + ed.InsertText("y"); // an edit elsewhere (the caret sits in paragraph 0) + Render(ed); + ed.RunScheduledLayoutCachePrune(); + + Assert.True(Cache(ed).Contains(keep)); + Assert.Equal(before, Cache(ed)[keep]); // the same entry — same TextLayout instance + } + + [AvaloniaFact] + public void ARenderWithoutAnEdit_SchedulesNothing() + { + var ed = Editor(5); + Assert.False(ed.LayoutCachePruneScheduled); + ed.InvalidateVisual(); + Render(ed); // a caret blink or a scroll repaint + Assert.False(ed.LayoutCachePruneScheduled); + } +}