diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b2005a7d..67db4b308 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -432,7 +432,8 @@ way to draw (`WindowsRendering`): `dcomp` (skip WinUI composition), `redirection surface) or `software` (no GPU); unset or `auto` keeps the defaults. Filee reads it at start. On ARM64, Avalonia's Adreno blocklist draws on the CPU through the Microsoft Basic Render Driver (issue #236), and three values try the GPU there again: `gpu` (Direct3D on the first adapter that isn't the Basic Render Driver), `vulkan` and `wgl` (OpenGL), the -last two falling back to software when they can't start. +last two falling back to software when they can't start. On Linux (X11) only `software` applies: the GPU's driver stays +in memory for as long as Filee runs, also with no window open, so this is the way to a smaller tray app (issue #957). ## Explorer integration diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index dc184a743..c1a68cc79 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -144,6 +144,8 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) log.LogInformation("{Rendering}", WindowsRendering.Describe(Environment.GetEnvironmentVariable(WindowsRendering.Variable))); TransparentWindows.Watch(log); } + else if (Program.LinuxDrawsInSoftware) + log.LogInformation("Rendering: software ({Variable})", WindowsRendering.Variable); // macOS: ask for Accessibility with the system's own alert when the user opened Filee (not at sign-in) and // a drag gesture is on, once ever (see ShouldAskForPermission). The home page keeps a reminder until it is // given. Asked once the main window is up: asked earlier, the alert ends up behind it. @@ -213,6 +215,9 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) }); }; WatchForUpdates(store); + // Started to the tray (at sign-in): no job or window would ask for a trim, however long Filee waits there (#957). + if (AppHost.Get().Main is null) + AppHost.Get().Request(); // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it): // say so once, next to the tray, and on the Home page for as long as it lasts. if (AppHost.Get().ShellCannotReachFilee) diff --git a/src/Filee.App/FileeFontCollection.cs b/src/Filee.App/FileeFontCollection.cs new file mode 100644 index 000000000..1fc0b0970 --- /dev/null +++ b/src/Filee.App/FileeFontCollection.cs @@ -0,0 +1,104 @@ +// The embedded Noto fonts (build/fetch-fonts.ps1), each family read when text first asks for it (issue #957). Avalonia's +// EmbeddedFontCollection opens every file at the start, and SkiaSharp copies a font it opens from a stream into native +// memory for good. So all 18 files (37 MB) stayed in memory on top of the pages of Filee.dll that the copying read, also +// in a Filee started to the tray that has drawn no text yet. Since a letter is only matched in the family asked for, the +// families after the one that has it are never asked for: Noto Sans SC stays unread until a chain reaches it. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia.Media; +using Avalonia.Media.Fonts; +using Avalonia.Platform; + +namespace Filee.App; + +/// +/// Embedded Noto fonts: fonts:Filee#Noto Sans, #Noto Sans JP, #Noto Sans KR, +/// #Noto Sans SC, #Noto Sans Devanagari, #Noto Sans Arabic, #Noto Sans Thai. +/// +/// A letter is only matched in the family asked for, so the order of SoftPalette's font chain decides (#1095). Avalonia's +/// own collection, asked for Noto Sans about a Han character, answers with the first family of its own that has it, +/// Noto Sans JP, before the font manager reaches the chain's next family: every language drew Han in Japanese shapes, +/// up to the first character that font lacks. Text has no culture to go by either, as TextBlock passes none. It takes +/// this wrapper rather than a subclass: the font manager skips the virtual method on Avalonia's collections. +/// +/// +internal sealed class FileeFontCollection : IFontCollection +{ + internal static readonly Uri Source = new("avares://Filee/Assets/Fonts", UriKind.Absolute); + + private readonly ReadFonts _fonts; + private readonly Lock _lock = new(); + private readonly HashSet _asked = new(StringComparer.OrdinalIgnoreCase); + + internal FileeFontCollection(string key = "fonts:Filee") => _fonts = new ReadFonts(new Uri(key, UriKind.Absolute)); + + public Uri Key => _fonts.Key; + + /// The families read so far. + public int Count => _fonts.Count; + + public FontFamily this[int index] => _fonts[index]; + + public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, + string? familyName, CultureInfo? culture, out Typeface typeface) + { + typeface = default; + // No culture from here on: the family is settled, and one would send it looking elsewhere again. + return familyName is not null + && TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face) + && face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) + && _fonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, null, out typeface); + } + + public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + { + ReadFamily(familyName); + return _fonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); + } + + public bool TryGetFamilyTypefaces(string familyName, [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) + { + ReadFamily(familyName); + return _fonts.TryGetFamilyTypefaces(familyName, out familyTypefaces); + } + + public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) => + _fonts.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); + + public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, + [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) + { + ReadFamily(familyName); + return _fonts.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface); + } + + public IEnumerator GetEnumerator() => _fonts.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public void Dispose() => ((IDisposable)_fonts).Dispose(); + + /// "NotoSansKR-Bold.otf" belongs to "Noto Sans KR", and not to "Noto Sans". + internal static bool IsFileOf(string familyName, string fileName) => + fileName.StartsWith(familyName.Replace(" ", "", StringComparison.Ordinal) + "-", StringComparison.OrdinalIgnoreCase); + + private void ReadFamily(string familyName) + { + lock (_lock) + { + if (!_asked.Add(familyName)) + return; + // None when the fonts weren't fetched: the family isn't found, and the chain goes on to the system's fonts. + foreach (var asset in AssetLoader.GetAssets(Source, null)) + if (IsFileOf(familyName, Path.GetFileName(asset.AbsolutePath))) + _fonts.TryAddFontSource(asset); + } + } + + private sealed class ReadFonts(Uri key) : FontCollectionBase + { + public override Uri Key => key; + } +} diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 7f80a15d9..fee11680b 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -7,12 +7,9 @@ // 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit). using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; -using Avalonia.Media.Fonts; using Filee.App.Services; using Filee.Core.Platform; using Filee.Core.Settings; @@ -293,6 +290,10 @@ public static AppBuilder BuildAvaloniaApp() WindowsRendering.Normalize(Environment.GetEnvironmentVariable(WindowsRendering.Variable)), adapters => Log.LogInformation("Graphics adapters: {Adapters}", adapters))); + // The GPU's driver stays in memory for as long as Filee runs, also with no window open (#957). + if (LinuxDrawsInSoftware) + builder = builder.With(new X11PlatformOptions { RenderingMode = [X11RenderingMode.Software] }); + builder = builder .ConfigureFonts(fonts => fonts.AddFontCollection(new FileeFontCollection())) #if DEBUG @@ -303,57 +304,8 @@ public static AppBuilder BuildAvaloniaApp() AvaloniaLogForwarder.Install(Log); return builder; } -} - -/// -/// Embedded Noto fonts: fonts:Filee#Noto Sans, #Noto Sans JP, #Noto Sans KR, -/// #Noto Sans SC, #Noto Sans Devanagari, #Noto Sans Arabic, #Noto Sans Thai. -/// -/// A letter is only matched in the family asked for, so the order of SoftPalette's font chain decides (#1095). Avalonia's -/// own collection, asked for Noto Sans about a Han character, answers with the first family of its own that has it, -/// Noto Sans JP, before the font manager reaches the chain's next family: every language drew Han in Japanese shapes, -/// up to the first character that font lacks. Text has no culture to go by either, as TextBlock passes none. It takes -/// this wrapper rather than a subclass: the font manager skips the virtual method on Avalonia's collections. -/// -/// -internal sealed class FileeFontCollection : IFontCollection -{ - private readonly EmbeddedFontCollection _fonts = new( - new Uri("fonts:Filee", UriKind.Absolute), - new Uri("avares://Filee/Assets/Fonts", UriKind.Absolute)); - - public Uri Key => _fonts.Key; - public int Count => _fonts.Count; - public FontFamily this[int index] => _fonts[index]; - - public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, - string? familyName, CultureInfo? culture, out Typeface typeface) - { - typeface = default; - // No culture from here on: the family is settled, and one would send it looking elsewhere again. - return familyName is not null - && _fonts.TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out var face) - && face.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) - && _fonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, null, out typeface); - } - - public bool TryGetGlyphTypeface(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, - [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => - _fonts.TryGetGlyphTypeface(familyName, style, weight, stretch, out glyphTypeface); - - public bool TryGetFamilyTypefaces(string familyName, - [NotNullWhen(true)] out IReadOnlyList? familyTypefaces) => - _fonts.TryGetFamilyTypefaces(familyName, out familyTypefaces); - - public bool TryCreateSyntheticGlyphTypeface(GlyphTypeface glyphTypeface, FontStyle style, FontWeight weight, FontStretch stretch, - [NotNullWhen(true)] out GlyphTypeface? syntheticGlyphTypeface) => - _fonts.TryCreateSyntheticGlyphTypeface(glyphTypeface, style, weight, stretch, out syntheticGlyphTypeface); - - public bool TryGetNearestMatch(string familyName, FontStyle style, FontWeight weight, FontStretch stretch, - [NotNullWhen(true)] out GlyphTypeface? glyphTypeface) => - _fonts.TryGetNearestMatch(familyName, style, weight, stretch, out glyphTypeface); - public IEnumerator GetEnumerator() => _fonts.GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); - public void Dispose() => ((IDisposable)_fonts).Dispose(); + /// FILEE_RENDERING=software on Linux. + internal static bool LinuxDrawsInSoftware => OperatingSystem.IsLinux() + && WindowsRendering.Normalize(Environment.GetEnvironmentVariable(WindowsRendering.Variable)) == "software"; } diff --git a/src/Filee.App/Services/MemoryTrimmer.cs b/src/Filee.App/Services/MemoryTrimmer.cs index 6692529f2..b4bd61d3d 100644 --- a/src/Filee.App/Services/MemoryTrimmer.cs +++ b/src/Filee.App/Services/MemoryTrimmer.cs @@ -1,10 +1,11 @@ -// Gives memory back while Filee waits in the tray (issue #119): a while after the last conversion finished, and after -// the main window was released. Large images grow the managed heap and the engines' caches, and the garbage collector +// Gives memory back while Filee waits in the tray (issues #119, #957): a while after the last conversion finished, after +// the main window was released, and after a start without a window. Large images grow the managed heap and the engines' caches, and the garbage collector // keeps that memory committed for the next allocation, so Filee stayed hundreds of MB above its idle size. using System.Diagnostics; using Filee.Core.Conversion; using Filee.Engines.Infrastructure; +using Filee.Platform.Linux; using Filee.Platform.Windows; using Microsoft.Extensions.Logging; @@ -74,25 +75,44 @@ private void OnQuiet() Interlocked.Increment(ref _trims); } - /// Drops the engines' caches, collects every generation, then trims the working set (Windows). + /// + /// Drops the engines' caches, collects every generation, then trims the working set (Windows) or gives malloc's free + /// memory back (Linux). + /// internal static void Trim(ILogger log) { try { var watch = Stopwatch.StartNew(); var before = PrivateMegabytes(); + var linuxBefore = ProcessMemoryUsage.Current(); EngineCaches.Release(); - // Without compacting: garbage is freed and empty heap regions are decommitted all the same. Compaction was - // once blamed for a CI crash in Skia (sk_pngencoder_encode) after a trim; the cause was tests that let a - // captured frame be collected while Avalonia encoded it (WriteableBitmap.Save encodes a snapshot that - // doesn't own the pixels, and the finalizers this trim runs freed them). Nothing in Filee leaves native - // code with a pointer into managed memory: NativeMemoryStressTests converts under constant compaction. GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); + // Aggressive: a forced collection frees the garbage but keeps the empty heap regions committed for the next + // allocation (hundreds of MB after a large conversion), and only this one decommits them all. It compacts. + // Compaction was once blamed for a CI crash in Skia (sk_pngencoder_encode) after a trim; the cause was tests + // that let a captured frame be collected while Avalonia encoded it (WriteableBitmap.Save encodes a snapshot + // that doesn't own the pixels, and the finalizers this trim runs freed them). Nothing in Filee leaves native + // code with a pointer into managed memory: NativeMemoryStressTests converts under constant compaction. + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); ProcessMemory.TrimWorkingSet(); - log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB) in {Elapsed} ms", - before, PrivateMegabytes(), GC.GetTotalMemory(false) / (1024 * 1024), watch.ElapsedMilliseconds); + NativeHeap.Trim(); + var committed = GC.GetGCMemoryInfo().TotalCommittedBytes / Megabyte; + if (linuxBefore is { } own && ProcessMemoryUsage.Current() is { } now) + { + // Windows' "private" means nothing here (see ProcessMemoryUsage). The split is what a report needs: what + // is the runtime's, what is malloc's, and what is left (mapped files, stacks, JIT code). + var malloc = NativeHeap.Usage(); + log.LogInformation("Trimmed memory: own {Before} MB → {After} MB, resident {Resident} MB, anonymous {Anonymous} MB " + + "(managed heap {Heap} MB of {Committed} MB committed, malloc {MallocUsed} MB used, {MallocFree} MB free) in {Elapsed} ms", + own.OwnBytes / Megabyte, now.OwnBytes / Megabyte, now.ResidentBytes / Megabyte, now.AnonymousBytes / Megabyte, + GC.GetTotalMemory(false) / Megabyte, committed, + malloc?.InUseBytes / Megabyte, malloc?.FreeBytes / Megabyte, watch.ElapsedMilliseconds); + return; + } + log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB of {Committed} MB committed) in {Elapsed} ms", + before, PrivateMegabytes(), GC.GetTotalMemory(false) / Megabyte, committed, watch.ElapsedMilliseconds); } catch (Exception ex) when (ex is not OutOfMemoryException) { @@ -100,10 +120,12 @@ internal static void Trim(ILogger log) } } + private const long Megabyte = 1024 * 1024; + private static long PrivateMegabytes() { using var process = Process.GetCurrentProcess(); - return process.PrivateMemorySize64 / (1024 * 1024); + return process.PrivateMemorySize64 / Megabyte; } public void Dispose() diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 970e9ff42..53bce58de 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -278,6 +278,7 @@ public void ShowImageEditWindow(IReadOnlyList files, Preset preset) { if (_imageEdit == win) _imageEdit = null; + trimmer.Request(); }; _imageEdit = win; win.Show(); @@ -408,6 +409,7 @@ public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoi { if (_cropOverlay == win) _cropOverlay = null; + trimmer.Request(); }; _cropOverlay = win; win.Show(); @@ -429,6 +431,7 @@ public void ShowPdfPageEditor(IReadOnlyList files, Preset preset, bool a { if (_pdfPageEditor == win) _pdfPageEditor = null; + trimmer.Request(); }; if (addPages) win.Opened += async (_, _) => await win.PickAndAddAsync(); diff --git a/src/Filee.Platform.Linux/NativeHeap.cs b/src/Filee.Platform.Linux/NativeHeap.cs new file mode 100644 index 000000000..b86f19ae9 --- /dev/null +++ b/src/Filee.Platform.Linux/NativeHeap.cs @@ -0,0 +1,50 @@ +// glibc's malloc, which Skia, FreeType, HarfBuzz, the runtime and the engines' libraries all share (issue #957). It keeps +// what they free for the next allocation and only gives back the end of its main heap, and it starts a new arena for +// threads that meet in malloc, up to 8 per CPU. Windows has MemoryTrimmer's working set trim for this; here the memory +// has to be asked for. +// +// Looked up by name while running: musl has no malloc_trim and no libc.so.6, and mallinfo2 needs glibc 2.33. What's missing +// is simply not done. "libc" alone won't do: on a system with development packages it finds libc.so, a linker script. + +using System.Runtime.InteropServices; + +namespace Filee.Platform.Linux; + +/// What malloc holds: handed out to native code, and freed but kept for the next allocation. +public readonly record struct NativeHeapUsage(long InUseBytes, long FreeBytes); + +public static class NativeHeap +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int MallocTrim(nuint pad); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate MallInfo2 MallInfo2Call(); + + /// struct mallinfo2: only the fields used are named, but all of them take their place. + [StructLayout(LayoutKind.Sequential)] + private struct MallInfo2 + { + public nuint Arena, Ordblks, Smblks, Hblks, Hblkhd, Usmblks, Fsmblks, Uordblks, Fordblks, Keepcost; + } + + private static readonly Lazy Libc = new(() => + OperatingSystem.IsLinux() && NativeLibrary.TryLoad("libc.so.6", out var handle) ? handle : 0); + + private static T? Bind(string name) where T : Delegate => + Libc.Value != 0 && NativeLibrary.TryGetExport(Libc.Value, name, out var export) + ? Marshal.GetDelegateForFunctionPointer(export) + : null; + + /// Returns malloc's free memory to the system, in every arena. False when there was none, or no glibc. + public static bool Trim() => Bind("malloc_trim")?.Invoke(0) == 1; + + /// What malloc holds right now; null when this C library can't say. + public static NativeHeapUsage? Usage() + { + if (Bind("mallinfo2") is not { } call) + return null; + var info = call(); + return new((long)info.Uordblks + (long)info.Hblkhd, (long)info.Fordblks); + } +} diff --git a/src/Filee.Platform.Linux/ProcessMemoryUsage.cs b/src/Filee.Platform.Linux/ProcessMemoryUsage.cs new file mode 100644 index 000000000..670927beb --- /dev/null +++ b/src/Filee.Platform.Linux/ProcessMemoryUsage.cs @@ -0,0 +1,53 @@ +// This process's memory as Linux counts it, from /proc/self/smaps_rollup (issue #957). Process.PrivateMemorySize64 won't +// do here: it reports VmData, which includes address space never touched. Parsing is pure, so it can be tested anywhere. + +namespace Filee.Platform.Linux; + +/// +/// Resident pages; those no other process maps (what smem calls USS); and those not backed by a file (managed heap, +/// malloc, stacks, code the JIT made). +/// +public readonly record struct ProcessMemoryUsage(long ResidentBytes, long OwnBytes, long AnonymousBytes) +{ + private const string RollupFile = "/proc/self/smaps_rollup"; + + /// This process's memory right now; null when the kernel doesn't offer it (before Linux 4.14) or off Linux. + public static ProcessMemoryUsage? Current() + { + if (!OperatingSystem.IsLinux()) + return null; + try + { + return Parse(File.ReadAllText(RollupFile)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// Reads the "Rss: 1234 kB" lines of a smaps_rollup file; null when it has no Rss. + internal static ProcessMemoryUsage? Parse(string rollup) + { + long? resident = null; + long privateClean = 0, privateDirty = 0, anonymous = 0; + foreach (var line in rollup.Split('\n', StringSplitOptions.TrimEntries)) + { + var colon = line.IndexOf(':'); + if (colon <= 0) + continue; + var parts = line[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2 || parts[1] != "kB" || !long.TryParse(parts[0], out var kilobytes)) + continue; + var bytes = kilobytes * 1024; + switch (line[..colon]) + { + case "Rss": resident = bytes; break; + case "Private_Clean": privateClean = bytes; break; + case "Private_Dirty": privateDirty = bytes; break; + case "Anonymous": anonymous = bytes; break; + } + } + return resident is { } rss ? new(rss, privateClean + privateDirty, anonymous) : null; + } +} diff --git a/tests/Filee.App.Tests/FileeFontCollectionTests.cs b/tests/Filee.App.Tests/FileeFontCollectionTests.cs new file mode 100644 index 000000000..20444b919 --- /dev/null +++ b/tests/Filee.App.Tests/FileeFontCollectionTests.cs @@ -0,0 +1,130 @@ +// The embedded fonts are read family by family when text asks (issue #957), and which families were read before never +// changes the font a letter ends up in: SoftPalette's chain decides (#1095). + +using System.Globalization; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Platform; +using Filee.App.Services; + +namespace Filee.App.Tests; + +public class FileeFontCollectionTests +{ + private const string Key = "fonts:FileeOnDemand"; + + private static readonly string[] Families = + ["Noto Sans", "Noto Sans Arabic", "Noto Sans Devanagari", "Noto Sans Thai", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC"]; + + private static void RequireFonts() + { + TestServices.EnsureInitialized("en"); + Assert.SkipUnless(AssetLoader.GetAssets(FileeFontCollection.Source, null).Any(), "Requires build/fetch-fonts.ps1."); + } + + /// A new collection in the font manager (it replaces and disposes the last, or the fonts would add up). + private static FileeFontCollection NewCollection() + { + var fonts = new FileeFontCollection(Key); + FontManager.Current.AddFontCollection(fonts); + return fonts; + } + + private static string? Match(string language, int letter, FontWeight weight = FontWeight.Normal) + { + var chain = ThemeService.FontFamilies(language).Replace("fonts:Filee", Key, StringComparison.Ordinal); + return FontManager.Current.TryMatchCharacter(letter, FontStyle.Normal, weight, FontStretch.Normal, FontFamily.Parse(chain), null, out var typeface) + ? typeface.GlyphTypeface.FamilyName + : null; + } + + [Theory] + [InlineData("Noto Sans", "NotoSans-Regular.ttf", true)] + [InlineData("Noto Sans", "NotoSans-Bold.ttf", true)] + [InlineData("Noto Sans", "NotoSansKR-Regular.otf", false)] + [InlineData("Noto Sans KR", "NotoSansKR-Bold.otf", true)] + [InlineData("Noto Sans KR", "NotoSansJP-Bold.otf", false)] + [InlineData("Noto Sans Devanagari", "NotoSansDevanagari-Medium.ttf", true)] + [InlineData("Segoe UI", "NotoSans-Regular.ttf", false)] + public void A_font_file_belongs_to_its_family(string family, string file, bool expected) => + Assert.Equal(expected, FileeFontCollection.IsFileOf(family, file)); + + [AvaloniaFact] + public void A_family_is_read_when_asked_for_and_not_before() + { + RequireFonts(); + using var fonts = new FileeFontCollection(Key); + Assert.Empty(fonts); + + Assert.True(fonts.TryGetGlyphTypeface("Noto Sans", FontStyle.Normal, FontWeight.Bold, FontStretch.Normal, out var bold)); + Assert.Equal(FontWeight.Bold, bold.Weight); + Assert.DoesNotContain(fonts, f => f.Name == "Noto Sans KR"); + + Assert.True(fonts.TryGetGlyphTypeface("Noto Sans KR", FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out var korean)); + Assert.NotEqual((ushort)0, korean.CharacterToGlyphMap.GetGlyph('한')); + Assert.DoesNotContain(fonts, f => f.Name == "Noto Sans JP"); + + Assert.False(fonts.TryGetGlyphTypeface("Not a font", FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out _)); + } + + [AvaloniaFact] + public void A_letter_is_only_matched_in_the_family_asked_for() + { + RequireFonts(); + using var fonts = new FileeFontCollection(Key); + Assert.False(fonts.TryMatchCharacter('한', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Noto Sans", null, out _)); + Assert.DoesNotContain(fonts, f => f.Name == "Noto Sans KR"); + Assert.True(fonts.TryMatchCharacter('한', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Noto Sans KR", null, out _)); + } + + [AvaloniaTheory] + [InlineData("en", "Noto Sans KR", "Noto Sans JP", "Noto Sans SC")] // other languages keep Korean first + [InlineData("ko", "Noto Sans KR", "Noto Sans JP", "Noto Sans SC")] + [InlineData("ja", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC")] + [InlineData("zh-CN", "Noto Sans SC", "Noto Sans JP", "Noto Sans KR")] + public void A_letter_Noto_Sans_lacks_reads_its_family_and_leaves_later_ones_unread(string language, string font, string unread1, string unread2) + { + RequireFonts(); + var fonts = NewCollection(); + + Assert.Equal("Noto Sans", Match(language, 'A')); + Assert.DoesNotContain(fonts, f => f.Name == font); + + Assert.Equal(font, Match(language, '将')); + Assert.Contains(fonts, f => f.Name == font); + Assert.DoesNotContain(fonts, f => f.Name == unread1); + Assert.DoesNotContain(fonts, f => f.Name == unread2); + + // The arrow between a format and what it becomes: Noto Sans has none, so the main window comes this far too. + Assert.False(fonts.TryMatchCharacter('→', FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, "Noto Sans", null, out _)); + Assert.Equal(font, Match(language, '→')); + Assert.DoesNotContain(fonts, f => f.Name == unread1); + } + + [AvaloniaTheory] + [InlineData("en")] + [InlineData("ko")] + [InlineData("ja")] + [InlineData("zh-CN")] + [InlineData("ar")] + public void A_letter_takes_the_same_font_whatever_was_read_before(string language) + { + RequireFonts(); + // The first letter a new collection sees is the hard case, so each letter gets a new one. + foreach (var letter in "A한あ将กनم→") + { + foreach (var weight in new[] { FontWeight.Normal, FontWeight.Bold }) + { + NewCollection(); + var firstLetter = Match(language, letter, weight); + + var fonts = NewCollection(); + foreach (var family in Families) + fonts.TryGetGlyphTypeface(family, FontStyle.Normal, FontWeight.Normal, FontStretch.Normal, out _); + var allRead = Match(language, letter, weight); + + Assert.True(firstLetter == allRead, $"{letter} {weight} in {language}: {firstLetter} when first, {allRead} with all read"); + } + } + } +} diff --git a/tests/Filee.App.Tests/MemoryTrimmerTests.cs b/tests/Filee.App.Tests/MemoryTrimmerTests.cs index 8e2665e29..60f110204 100644 --- a/tests/Filee.App.Tests/MemoryTrimmerTests.cs +++ b/tests/Filee.App.Tests/MemoryTrimmerTests.cs @@ -2,6 +2,7 @@ // a new reason. using Filee.App.Services; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App.Tests; @@ -46,6 +47,24 @@ public async Task Never_trims_while_a_conversion_runs() Assert.False(trimmedWhileBusy); } + [Fact] + public void Trimming_decommits_the_managed_heap_that_was_freed() + { + // A heap grown by a large conversion and its garbage: without decommitting, the regions stay committed. + var filled = new List(); + for (var i = 0; i < 300; i++) + filled.Add(new byte[1024 * 1024]); + var peak = GC.GetTotalMemory(false); + filled.ForEach(block => Array.Fill(block, (byte)1)); + filled.Clear(); + + MemoryTrimmer.Trim(NullLogger.Instance); + + // Other tests may run meanwhile: most of the 300 MB is gone, not all of it. + Assert.True(GC.GetGCMemoryInfo().TotalCommittedBytes < peak - 150L * 1024 * 1024, + $"{GC.GetGCMemoryInfo().TotalCommittedBytes} bytes stayed committed of {peak}"); + } + [Fact] public async Task A_new_request_moves_the_trim_on() { diff --git a/tests/Filee.Platform.Tests/LinuxMemoryTests.cs b/tests/Filee.Platform.Tests/LinuxMemoryTests.cs new file mode 100644 index 000000000..3c11e85aa --- /dev/null +++ b/tests/Filee.Platform.Tests/LinuxMemoryTests.cs @@ -0,0 +1,80 @@ +// What the idle trim reads and asks of Linux (issue #957): the parsing runs everywhere, the C library calls on Linux. + +using System.Runtime.InteropServices; +using Filee.Platform.Linux; + +namespace Filee.Platform.Tests; + +public sealed class LinuxMemoryTests +{ + private const string Rollup = """ + 55d0c1a00000-7ffd3f7ff000 ---p 00000000 00:00 0 [rollup] + Rss: 388424 kB + Pss: 326550 kB + Pss_Anon: 250000 kB + Pss_File: 76550 kB + Pss_Shmem: 0 kB + Shared_Clean: 76140 kB + Shared_Dirty: 0 kB + Private_Clean: 12284 kB + Private_Dirty: 300000 kB + Referenced: 380000 kB + Anonymous: 299000 kB + LazyFree: 0 kB + AnonHugePages: 0 kB + Swap: 0 kB + Locked: 0 kB + """; + + [Fact] + public void Rollup_gives_resident_own_and_anonymous_memory() + { + var usage = ProcessMemoryUsage.Parse(Rollup); + + Assert.Equal(388424L * 1024, usage?.ResidentBytes); + Assert.Equal(312284L * 1024, usage?.OwnBytes); // Private_Clean + Private_Dirty: the USS of smem + Assert.Equal(299000L * 1024, usage?.AnonymousBytes); + } + + [Theory] + [InlineData("")] + [InlineData("Private_Dirty: 12 kB")] + [InlineData("Rss: many kB")] + public void A_rollup_without_a_readable_rss_gives_nothing(string text) => + Assert.Null(ProcessMemoryUsage.Parse(text)); + + [Fact] + public void Off_Linux_nothing_is_asked_of_the_C_library() + { + if (OperatingSystem.IsLinux()) + { + Assert.Skip("Linux has these calls."); + return; + } + Assert.False(NativeHeap.Trim()); + Assert.Null(NativeHeap.Usage()); + Assert.Null(ProcessMemoryUsage.Current()); + } + + [Fact] + public void Trimming_gives_back_what_malloc_kept_on_linux() + { + if (!OperatingSystem.IsLinux() || NativeHeap.Usage() is null) + { + Assert.Skip("Requires glibc."); + return; + } + Assert.NotNull(ProcessMemoryUsage.Current()); + + // Small blocks stay in the arena when freed; the last one keeps the heap from simply shrinking at its end. + var blocks = Enumerable.Range(0, 64 * 1024).Select(_ => Marshal.AllocHGlobal(1024)).ToArray(); + foreach (var block in blocks.SkipLast(1)) + Marshal.FreeHGlobal(block); + var kept = NativeHeap.Usage()!.Value.FreeBytes; + Assert.True(kept > 32 * 1024 * 1024, $"malloc kept {kept} bytes"); + + Assert.True(NativeHeap.Trim()); + Assert.True(NativeHeap.Usage()!.Value.InUseBytes > 0); + Marshal.FreeHGlobal(blocks[^1]); + } +}