Skip to content

Access violation in SkiaTypeface.TryGetStream: SKStreamAsset can be finalized while sk_stream_read is copying (synthetic bold / oblique) #22323

Description

@liziaaa22

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions