Describe the bug
Avalonia.Skia.SkiaTypeface.TryGetStream (12.1.2; unchanged in 12.1.3 and on main as of 2026-09-27) reads the whole font file like this:
var asset = SKTypeface.OpenStream(); // never disposed, not kept alive
var size = asset.Length;
var buffer = new byte[size];
asset.Read(buffer, size); // SkiaSharp 3.119.4: no GC.KeepAlive(this) in SKStream.Read(IntPtr, int)
stream = new MemoryStream(buffer);
Once sk_stream_read has been entered, asset is no longer reachable from managed code. If another thread triggers a GC during the copy, the SKStreamAsset finalizer runs sk_stream_asset_destroy on the finalizer thread while the UI thread is still inside SkDWriteFontFileStream::read → use-after-free → the process dies with 0xC0000005 (on Windows the fault is at SkDWriteFontFileStream::read+0x92, right after the memcpy, when it dereferences the already freed fFontFileStream).
TryGetStream is reached from FontCollectionBase.TryCreateSyntheticGlyphTypeface every time a synthetic bold (or oblique) face is created: e.g. a family whose Regular face is already cached is then asked for SemiBold. For "Microsoft YaHei" that copies the whole 19.7 MB msyh.ttc (5–11 ms on our machines), for "Segoe UI Symbol" 2.5 MB. The wider the copy window and the busier the other threads, the more likely the crash. We see it as rare, random start-up crashes of a NativeAOT app (Windows 10/11, x64).
To Reproduce
Minimal repro without Avalonia (same four lines, official SkiaSharp 3.119.4 + SkiaSharp.NativeAssets.Win32, .NET 10, TieredCompilation=false):
using SkiaSharp;
var tf = SKTypeface.FromFamilyName("Microsoft YaHei");
new Thread(() => { while (true) { _ = new byte[4096]; GC.Collect(0, GCCollectionMode.Forced, blocking: true); } }) { IsBackground = true }.Start();
for (int i = 0; i < 200; i++)
{
SKStreamAsset asset = tf.OpenStream(); // exactly what SkiaTypeface.TryGetStream does
var buffer = new byte[asset.Length];
asset.Read(buffer, buffer.Length);
}
Console.WriteLine("no crash");
Result: 10/10 runs crash with Fatal error. 0xC0000005 at SkiaSharp.SkiaApi.sk_stream_read. With using plus GC.KeepAlive(asset) after Read: 0/10.
In a real Avalonia 12.1.2 app we traced every IFontManagerImpl.TryCreateGlyphTypeface(Stream, …) call and reproduced the crash through TextBlock measure → FontManager.TryMatchCharacter → FontCollectionBase.TryCreateSyntheticGlyphTypeface → SkiaTypeface.TryGetStream → SKStream.Read → sk_stream_read.
Expected behavior
No crash. Suggested fix (keeps the current behaviour):
using var asset = SKTypeface.OpenStream();
using keeps asset reachable until Dispose, so the finalizer cannot run during Read, and it also frees the native stream promptly instead of waiting for the finalizer. (SkiaSharp 4.x added GC.KeepAlive(this) to its P/Invoke wrappers in mono/SkiaSharp#4080, which would also avoid the crash, but Avalonia 12.1.x still pins SkiaSharp 3.119.4.)
A possible follow-up: for platform typefaces a synthetic bold/oblique face does not need a copy of the whole file at all — a SkiaTypeface over the same SKTypeface with FontSimulations.Bold would do (that is what FontManagerImpl.TryCreateGlyphTypeface(string, …) already returns when the platform has no bold face), saving ~20 MB and several ms per synthetic face for CJK fonts.
Avalonia version
12.1.2 (12.1.3 and main source checked: TryGetStream unchanged)
OS
No response
Additional context
Environment
- OS: Windows 10 / 11 x64
- SkiaSharp: 3.119.4 (official native assets); also reproduced with a self-built static libSkiaSharp
- .NET 10, NativeAOT and JIT (
TieredCompilation=0)
Possibly related: #22214 (macOS native memory growth through the same TryGetStream → SKTypeface.FromStream path; a different symptom, but it also notes that the stream from SKTypeface.OpenStream() is never disposed).
Workaround we use
Before the first window, request every heavy face once so later text never needs a synthetic copy: for families with a real bold face ask Bold then SemiBold (SemiBold then resolves to the real bold); for "Segoe UI Symbol" (no bold) ask Regular → SemiBold → Bold inside GC.TryStartNoGCRegion, so the one unavoidable copy cannot race with a GC.
Describe the bug
Avalonia.Skia.SkiaTypeface.TryGetStream(12.1.2; unchanged in 12.1.3 and onmainas of 2026-09-27) reads the whole font file like this:Once
sk_stream_readhas been entered,assetis no longer reachable from managed code. If another thread triggers a GC during the copy, theSKStreamAssetfinalizer runssk_stream_asset_destroyon the finalizer thread while the UI thread is still insideSkDWriteFontFileStream::read→ use-after-free → the process dies with0xC0000005(on Windows the fault is atSkDWriteFontFileStream::read+0x92, right after thememcpy, when it dereferences the already freedfFontFileStream).TryGetStreamis reached fromFontCollectionBase.TryCreateSyntheticGlyphTypefaceevery time a synthetic bold (or oblique) face is created: e.g. a family whose Regular face is already cached is then asked forSemiBold. For "Microsoft YaHei" that copies the whole 19.7 MBmsyh.ttc(5–11 ms on our machines), for "Segoe UI Symbol" 2.5 MB. The wider the copy window and the busier the other threads, the more likely the crash. We see it as rare, random start-up crashes of a NativeAOT app (Windows 10/11, x64).To Reproduce
Minimal repro without Avalonia (same four lines, official SkiaSharp 3.119.4 + SkiaSharp.NativeAssets.Win32, .NET 10,
TieredCompilation=false):Result: 10/10 runs crash with
Fatal error. 0xC0000005 at SkiaSharp.SkiaApi.sk_stream_read. WithusingplusGC.KeepAlive(asset)afterRead: 0/10.In a real Avalonia 12.1.2 app we traced every
IFontManagerImpl.TryCreateGlyphTypeface(Stream, …)call and reproduced the crash throughTextBlockmeasure →FontManager.TryMatchCharacter→FontCollectionBase.TryCreateSyntheticGlyphTypeface→SkiaTypeface.TryGetStream→SKStream.Read→sk_stream_read.Expected behavior
No crash. Suggested fix (keeps the current behaviour):
usingkeepsassetreachable untilDispose, so the finalizer cannot run duringRead, and it also frees the native stream promptly instead of waiting for the finalizer. (SkiaSharp 4.x addedGC.KeepAlive(this)to its P/Invoke wrappers in mono/SkiaSharp#4080, which would also avoid the crash, but Avalonia 12.1.x still pins SkiaSharp 3.119.4.)A possible follow-up: for platform typefaces a synthetic bold/oblique face does not need a copy of the whole file at all — a
SkiaTypefaceover the sameSKTypefacewithFontSimulations.Boldwould do (that is whatFontManagerImpl.TryCreateGlyphTypeface(string, …)already returns when the platform has no bold face), saving ~20 MB and several ms per synthetic face for CJK fonts.Avalonia version
12.1.2 (12.1.3 and main source checked: TryGetStream unchanged)
OS
No response
Additional context
Environment
TieredCompilation=0)Possibly related: #22214 (macOS native memory growth through the same
TryGetStream→SKTypeface.FromStreampath; a different symptom, but it also notes that the stream fromSKTypeface.OpenStream()is never disposed).Workaround we use
Before the first window, request every heavy face once so later text never needs a synthetic copy: for families with a real bold face ask
BoldthenSemiBold(SemiBold then resolves to the real bold); for "Segoe UI Symbol" (no bold) ask Regular → SemiBold → Bold insideGC.TryStartNoGCRegion, so the one unavoidable copy cannot race with a GC.