From 8d2b696fa6fbc33b3965c5bb349a0648d16a82e4 Mon Sep 17 00:00:00 2001 From: centwon Date: Tue, 29 Sep 2026 21:58:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83?= =?UTF-8?q?=20LRU=EA=B0=80=20=EA=B7=B8=20=ED=8C=A8=EC=8A=A4=20=EC=9E=90?= =?UTF-8?q?=EC=8B=A0=EC=9D=98=20=EC=9E=91=EC=97=85=20=EC=A7=91=ED=95=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9E=90=EB=A5=B4=EB=8D=98=20=EA=B2=83=20(#59=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 드로 패스 끝의 TrimLayoutCache가 사용 순서로만 256개를 남겨, 한 패스가 384개를 넘게 쓰면 먼저 그린 레이아웃을 버렸다. 캐럿 깜빡임이 캔버스 전체를 다시 그리므로 변경 없이 0.5초마다 346개를 만들고 버렸다(30x20 표, LayoutBuilds 훅으로 계측). 상류 WIP의 같은 LRU처럼 패스 시작 이후 쓴 항목은 건드리지 않는다. - 테스트: 변경 없는 패스는 레이아웃을 만들지 않는다(반증: 보호를 끄면 346) - 테스트: 트리에서 빼기 -> API 편집 -> 다시 넣기 2회(반증 2종 빨강) - 로드맵·CHANGELOG에 감사 결과 기록 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + Project_Roadmap.md | 24 ++++- .../Controls/RichEditor.Rendering.cs | 3 +- src/WinUIRichEditor/Controls/RichEditor.cs | 20 ++-- .../ControlMemoryTrimTests.cs | 94 ++++++++++++++++++- 5 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ee766..91ab629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and follows [Semantic Versioning](https://semver.org/). - **문단 레이아웃 캐시가 스크롤에 비례해 자랐다.** 뷰포트 크기일 거라던 캐시가 끝까지 스크롤하면 2,000문단 전부를 들고 있었다(2048에서 전부 비우는 안전망까지). 이제 그리기가 끝날 때마다 최근에 쓴 256개로 줄인다(텍스트 전용 2,000문단: 스크롤 뒤 152 → 136 MB). 줄어든 문단은 다시 보일 때 레이아웃을 새로 만든다 — CPU만 쓰고 화면엔 차이가 없다. + 단 **방금 그린 것은 줄이지 않는다**(2026-09-29 감사에서 수정): 처음 모양은 그 그리기 자신이 쓴 레이아웃까지 잘라, + 화면에 문단·셀이 384개를 넘게 보이면(예: 30×20 표) 아무것도 안 바뀐 캐럿 깜빡임마다 346개를 새로 만들고 버렸다. - **시각 트리에서 빠진 에디터가 아무것도 놓지 않았다.** 이제 빠질 때 레이아웃을 모두 놓고, 화면 근처가 아닌 그림을 해제한다(화면에 있던 그림은 남겨 돌아올 때 바로 보인다). - **그림을 해제한 뒤 Win2D 디바이스를 다듬는다**(`CanvasDevice.Trim`). 비트맵을 버려도 드라이버가 올리기에 쓴 메모리를 diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 055aac7..84dc92b 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -12,13 +12,13 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 --- -## ✅ 현재 상태 (2026-09-24 · `1.2.0` 게시, 미게시 변경 누적 중) +## ✅ 현재 상태 (2026-09-29 · `1.2.0` 게시, 미게시 변경 누적 중) | | | |---|---| | 릴리스 | 1.0.0(07-31, **API 동결**) → 1.1.0(08-07) → 1.1.1(09-06, AOT 결함 수정) → **1.2.0(09-20 게시)** | | 빌드 | 라이브러리 0 warn / 0 err (테스트 프로젝트에 xUnit1031 1건 — 블로킹 대기, 기존) | -| 테스트 | **951**(2026-09-27, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 | +| 테스트 | **953**(2026-09-29, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 | | AOT | self-contained 게시 성공(2026-09-20): 네이티브 exe **15.2MB**, 게시 **74.6MB**(pdb 제외), CoreCLR·관리 dll 없음 | | 공개 표면 | **574** — `PublicAPI.Shipped.txt`로 추적, 1.2.0은 **추가 7**(breaking 없음) | @@ -65,7 +65,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 3. ~~**상류 백포트 잔여**~~ → **상류 PR #53까지 대조 끝**(2026-09-24). 라운드34(#53), 페이지 여백(#50·#52), 표 행·열 공개 API(#48), 단축키 표 공개(#49) — CHANGELOG `[Unreleased]`. (`Gesture(id)`는 Avalonia 전용이라 뺐다. enum 순서는 포트 것 유지.) -4. **다음 게시(1.3.0) 전에 남은 검증** — 아래 "미게시 변경분 감사" 절의 남은 항목. +4. **다음 게시(1.3.0) 전에 남은 검증** — 아래 "미게시 변경분 감사"는 끝났다. #59 감사(2026-09-29)도 끝났고, + 남은 것은 **AOT 동등성 스윕 재실행** 하나다. 5. 아래 "알려진 한계"의 미수정 항목. ### 미게시 변경분 감사 (2026-09-24) — 결함 3(상류 공통), 테스트 910 → 930, 반증 완료 @@ -126,6 +127,23 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 - **실기 확인 완료**(2026-09-28, 사용자, 문제 없음): 긴 사진 문서를 끝까지 스크롤했다 돌아올 때의 자리표시, 탭 전환 뒤 그림 표시, 툴바 글꼴 목록 전체. PR #59. +### PR #59 감사 (2026-09-29) — 결함 1(성능), 테스트 +2, 반증 3 +09-24 감사 뒤에 들어온 자기 변경분(메모리 쥐어짜기, 라이브러리 ~250줄)만 골라 감사했다. 정독형 전수조사는 하지 않았다. +- **결함: 레이아웃 LRU가 그 패스 자신의 작업 집합을 잘랐다.** 한 패스가 384개(256 + 여유 128)를 넘게 쓰면, 먼저 그린 것이 + 패스 끝에는 가장 오래된 것이 되어 버려진다. 캐럿 깜빡임은 캔버스 전체를 다시 그리므로 **아무 변경 없이 0.5초마다 346개**를 + 만들고 버렸다(30×20 표, `LayoutBuilds` 훅으로 계측). 첫 프로브는 "재생성 0"으로 나왔다 — 남은 256개 집합만 비교해서다. + **남은 것 말고 만든 수를 셀 것.** 단서는 상류 대조였다: 상류 WIP(`perf/layout-cache-measure-split`)의 같은 LRU가 + `renderStart` 이후 쓴 것은 건드리지 않는다. 같은 모양으로 고쳤다(`TrimLayoutCache(passStart)`). 반증: 보호를 끄면 346. +- 문제없음 확인(추적): 레이아웃을 await 너머로 쥐는 경로 없음(`BuildTextLayout` 호출부 전부 동기, 인라인 표 walk는 핀) · + 캔버스 무효화는 전부 전체 무효화라 보이는 그림의 위치 기록이 늘 새로 찍힘 · 인쇄/벡터 PDF는 D2D가 비트맵 참조를 따로 잡고 + 해제된 그림은 "한 번도 안 그린 그림"과 같은 상태(기존 인쇄 테스트가 덮음) · `CanvasDevice.Trim`은 Win2D가 D2D 리소스 잠금을 + 잡고 부른다(멀티스레드 팩토리 — 다른 스레드의 Win2D 렌더와 겹쳐도 안전). +- 새 테스트: 트리에서 빼기 → API 편집 → 다시 넣기 2회(반증 2: 표식/문단 레이아웃을 Dispose만 하고 남기면 다시 그릴 때 실패) · + 변경 없는 패스는 레이아웃을 만들지 않는다. +- 알고 둔 것: 같은 그림(내용 해시가 같은 키)이 멀리 떨어진 두 곳에 있으면 위치 기록은 마지막으로 그린 쪽 하나다. 화면에 있는 + 쪽이 해제되는 경우가 있어도 결과는 다음 그리기의 자리표시 한 번뿐이다. +- **남은 것**: AOT 동등성 스윕 재실행(#59 이후 전체 스윕 없음 — 사람이 있는 데스크톱에서). + ### 클립보드 경합 재시도 (2026-09-24, PR #55) 클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜 붙여넣기 6%·복사 6%가 조용히 실패했다(개발 PC 실측). 그 오류만 **await**로 20 ms × 10회 재시도 — `Thread.Sleep`은 diff --git a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs index 2605f92..54434c4 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Rendering.cs @@ -27,6 +27,7 @@ public partial class RichEditor // (plain paragraphs outside the clip advance by their cached height — see DrawContentWalk). private void OnRegionsInvalidated(CanvasVirtualControl sender, CanvasRegionsInvalidatedEventArgs args) { + long passStart = _layoutStamp; // layouts used from here on are this pass's own (TrimLayoutCache) foreach (var region in args.InvalidatedRegions) { try @@ -51,7 +52,7 @@ private void OnRegionsInvalidated(CanvasVirtualControl sender, CanvasRegionsInva } } // The draw pass is over and nothing holds a layout or a bitmap: bound what scrolling left behind. - TrimLayoutCache(); + TrimLayoutCache(passStart); TrimOffscreenImages(ImageOffscreenBytes); DrawPasses++; } diff --git a/src/WinUIRichEditor/Controls/RichEditor.cs b/src/WinUIRichEditor/Controls/RichEditor.cs index 8e6004e..b67a832 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.cs @@ -110,20 +110,27 @@ private sealed class LayoutEntry(long sig, double width, CanvasTextLayout layout // (the slack keeps it from sorting on every frame). A trimmed paragraph that comes back into view rebuilds // its layout — CPU only, nothing visible. Safe where the cap is not: at the end of a draw pass no walk is // holding a layout, so no in-use layout can be disposed, whatever the number. + // ⚠ Never one the pass itself used (stamped after `passStart`): a visible table draws every cell, and trimming + // a pass's own working set had every caret blink (a full-canvas pass twice a second) rebuild and drop it — + // audit of 2026-09-29: a 30×20 table on screen built and disposed 346 native layouts per blink. The upstream + // peer's version of this trim (in progress 2026-09-28) guards the same way. private const int LayoutKeep = 256, LayoutTrimSlack = 128; - private void TrimLayoutCache() + private void TrimLayoutCache(long passStart) { if (_layoutPinDepth != 0 || _layoutCache.Count <= LayoutKeep + LayoutTrimSlack) return; - var byAge = new List>(_layoutCache); - byAge.Sort((a, b) => a.Value.Stamp.CompareTo(b.Value.Stamp)); - for (int i = 0; i < byAge.Count - LayoutKeep; i++) + var old = new List>(); + foreach (var kv in _layoutCache) if (kv.Value.Stamp <= passStart) old.Add(kv); + old.Sort((a, b) => a.Value.Stamp.CompareTo(b.Value.Stamp)); + for (int i = 0; i < old.Count && _layoutCache.Count > LayoutKeep; i++) { - byAge[i].Value.Layout.Dispose(); - _layoutCache.Remove(byAge[i].Key); + old[i].Value.Layout.Dispose(); + _layoutCache.Remove(old[i].Key); } } + internal int LayoutBuilds; // test hook: layouts BuildTextLayout had to create (cache misses) + // ---- the per-paragraph side caches ------------------------------------ // Height, pagination lines and text statistics are cached per paragraph, keyed by identity and // validated by ParagraphSig — so a stale entry is impossible. What a Dictionary could @@ -550,6 +557,7 @@ internal CanvasTextLayout BuildTextLayout(Paragraph p, double maxWidth) } var layout = CreateLayout(p, maxWidth); + LayoutBuilds++; if (_layoutCache.TryGetValue(p, out var prev)) { prev.Layout.Dispose(); _layoutCache.Remove(p); } // Don't evict while a walk holds a cached layout (see _layoutPinDepth): an inline-table walk keeps // the HOST paragraph's layout live across building every cell layout, so a clear-all here would diff --git a/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs b/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs index 0ad82c0..c4bdc01 100644 --- a/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs +++ b/tests/WinUIRichEditor.Tests/ControlMemoryTrimTests.cs @@ -148,7 +148,9 @@ private static FlowDocument Paragraphs(int n) } private static IDictionary LayoutCache(RichEditor ed) => (IDictionary)T.GetField("_layoutCache", NP)!.GetValue(ed)!; - private static void TrimLayoutCache(RichEditor ed) => T.GetMethod("TrimLayoutCache", NP)!.Invoke(ed, null); + // As a draw pass that used none of them: every entry predates `passStart`. + private static void TrimLayoutCache(RichEditor ed) + => T.GetMethod("TrimLayoutCache", NP)!.Invoke(ed, [(long)T.GetField("_layoutStamp", NP)!.GetValue(ed)!]); [Fact] public void TheLayoutCache_IsTrimmedToTheMostRecentlyUsed_AndDisposesWhatItDrops() @@ -239,6 +241,44 @@ public void ADrawPass_TrimsTheLayoutCacheItFilled() finally { Unhost(ed); } } + // A table whose cells are all on screen at once: every cell paragraph is a layout the draw pass needs. + private static FlowDocument VisibleTable(int rows, int cols) + { + var tb = new TableBlock(rows, cols); + for (int r = 0; r < rows; r++) + for (int c = 0; c < cols; c++) + tb.Cells[r][c] = new TableCell(new Paragraph { Inlines = { new Run { Text = $"{r}.{c}", FontSize = 6 } } }); + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph()); + doc.Blocks.Add(tb); + doc.Blocks.Add(new Paragraph()); + return doc; + } + + // A pass that changes nothing — what every caret blink is, twice a second — must build nothing. With the trim + // cutting the pass's own working set back to LayoutKeep, 600 visible cells built and disposed 346 layouts a + // pass: the ones drawn first were the oldest by the end of it. (Audit of PR #59, 2026-09-29.) + [Fact] + public void APassThatChangesNothing_BuildsNoLayout_EvenWhenMoreThanLayoutKeepAreOnScreen() + { + var ed = HostedWith(VisibleTable(30, 20)); + try + { + UiThread.Run(() => ed.Zoom = 0.5); // the whole table in view, whatever the host window's size + WaitForDraw(ed); + WaitForDraw(ed); + int builds = UiThread.Run(() => ed.LayoutBuilds); + WaitForDraw(ed); + UiThread.Run(() => + { + Assert.True(ed.LayoutCacheCount >= 600, + $"the visible cells were not all drawn ({ed.LayoutCacheCount}): nothing is tested"); + Assert.Equal(0, ed.LayoutBuilds - builds); + }); + } + finally { Unhost(ed); } + } + // ---- leaving the visual tree ---------------------------------------------------------------------------- private static ImageCache Cache(RichEditor ed) => (ImageCache)T.GetField("_images", NP)!.GetValue(ed)!; @@ -326,6 +366,58 @@ public void Unloading_ReleasesTheLayouts_AndThePicturesFarFromView_KeepsTheNearO WaitUntil(() => ed.DeviceTrims > trimsBefore, "the deferred device trim"); } + private static IDictionary MarkerLayouts(RichEditor ed) => (IDictionary)T.GetField("_markerLayouts", NP)!.GetValue(ed)!; + + // The Unloaded release disposes every layout (paragraphs AND list markers) while the editor still has a + // document a host can edit through the API. Anything that kept a layout past the release — a side cache, + // a field — would hand the next draw a disposed one. Two cycles: the second releases what the edits made + // while hidden. A disposed layout throws out of the draw handler, so the pass never completes and WaitForDraw + // times out — falsified both ways (markers or paragraph layouts disposed but left cached: red on the reload + // draw). (Audit of PR #59, 2026-09-29.) + [Fact] + public void EditingWhileUnloaded_AndComingBack_DrawsFromFreshLayouts_ThroughTwoCycles() + { + var doc = Paragraphs(400); + foreach (var p in doc.Blocks.OfType().Take(5)) p.ListType = ListKind.Bullet; + var ed = HostedWith(doc); + var faults = new List(); + void OnFault(object? _, RichEditorFaultEventArgs e) { lock (faults) faults.Add(e.Exception); } + RichEditorDiagnostics.Reset(); + RichEditorDiagnostics.Fault += OnFault; + try + { + UiThread.Run(() => Assert.True(ed.LayoutCacheCount > 0 && MarkerLayouts(ed).Count > 0, + "nothing was drawn: the release would have nothing to dispose")); + for (int cycle = 0; cycle < 2; cycle++) + { + Unhost(ed); + WaitUntil(() => !ed.IsLoaded && ed.LayoutCacheCount == 0, "the Unloaded release"); + UiThread.Run(() => + { + Assert.Empty(MarkerLayouts(ed)); + ed.InsertText($"typed while hidden {cycle} "); + ed.ToggleBold(); + ed.InsertTable(2, 2); + Assert.True(ed.LayoutCacheCount > 0, "the edits built no layout: the next cycle releases nothing new"); + }); + UiThread.Host(ed); + WaitForDraw(ed); + UiThread.Run(() => + { + Assert.True(MarkerLayouts(ed).Count > 0, "the list markers were not drawn again"); + Assert.Contains($"typed while hidden {cycle}", ed.GetPlainText()); + }); + } + lock (faults) Assert.DoesNotContain(faults, e => e is ObjectDisposedException); + } + finally + { + RichEditorDiagnostics.Fault -= OnFault; + RichEditorDiagnostics.Reset(); + UiThread.Run(() => { if (ed.Parent is Microsoft.UI.Xaml.Controls.Panel p) p.Children.Remove(ed); }); + } + } + // ---- the font list ---------------------------------------------------------------------------------------- [Fact] From a5b2496ce65e3fcb956b4247b1e55913137d084f Mon Sep 17 00:00:00 2001 From: centwon Date: Tue, 29 Sep 2026 23:53:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A1=9C=EB=93=9C=EB=A7=B5=20?= =?UTF-8?q?=E2=80=94=20AOT=20=EB=8F=99=EB=93=B1=EC=84=B1=20=EC=8A=A4?= =?UTF-8?q?=EC=9C=95=20=EC=9E=AC=EC=8B=A4=ED=96=89=20=EA=B2=B0=EA=B3=BC(20?= =?UTF-8?q?26-09-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit control·toolbar 두 페이지 모두 JIT/AOT 되읽은 문서 바이트 동일(711자), 여백·글꼴 프로브 동일(글꼴 236개). AOT 전용 fault는 알려진 LineMetricsOf 폴백 하나. Co-Authored-By: Claude Opus 5.5 --- Project_Roadmap.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Project_Roadmap.md b/Project_Roadmap.md index 84dc92b..ac9cdf9 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -65,8 +65,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 3. ~~**상류 백포트 잔여**~~ → **상류 PR #53까지 대조 끝**(2026-09-24). 라운드34(#53), 페이지 여백(#50·#52), 표 행·열 공개 API(#48), 단축키 표 공개(#49) — CHANGELOG `[Unreleased]`. (`Gesture(id)`는 Avalonia 전용이라 뺐다. enum 순서는 포트 것 유지.) -4. **다음 게시(1.3.0) 전에 남은 검증** — 아래 "미게시 변경분 감사"는 끝났다. #59 감사(2026-09-29)도 끝났고, - 남은 것은 **AOT 동등성 스윕 재실행** 하나다. +4. ~~**다음 게시(1.3.0) 전에 남은 검증**~~ → 미게시 변경분 감사(09-24), #59 감사와 AOT 동등성 스윕 재실행(09-29) + 모두 끝났다. 다음은 1.3.0 게시 절차. 5. 아래 "알려진 한계"의 미수정 항목. ### 미게시 변경분 감사 (2026-09-24) — 결함 3(상류 공통), 테스트 910 → 930, 반증 완료 @@ -142,7 +142,9 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트 변경 없는 패스는 레이아웃을 만들지 않는다. - 알고 둔 것: 같은 그림(내용 해시가 같은 키)이 멀리 떨어진 두 곳에 있으면 위치 기록은 마지막으로 그린 쪽 하나다. 화면에 있는 쪽이 해제되는 경우가 있어도 결과는 다음 그리기의 자리표시 한 번뿐이다. -- **남은 것**: AOT 동등성 스윕 재실행(#59 이후 전체 스윕 없음 — 사람이 있는 데스크톱에서). +- ~~**AOT 동등성 스윕 재실행**~~ → **2026-09-29 실측, 이 수정 포함**: `control`·`toolbar` 두 페이지 모두 JIT/AOT의 되읽은 + 문서가 **바이트 동일**(711자, 타이핑한 문장 포함), 여백·글꼴 프로브 동일(글꼴 236개 — 폴백이면 6개). AOT 전용 fault는 + `LineMetricsOf` 하나(알려진 폴백, 1.2.0과 같음). AOT 게시: 네이티브 exe 15.2 MB, 게시 74.7 MB(pdb 제외). ### 클립보드 경합 재시도 (2026-09-24, PR #55) 클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜