Skip to content

Fix #3817: avoid re-entrant singleton cctor NullReferenceException#4107

Closed
mattleibow wants to merge 1 commit into
mainfrom
mattleibow/fix-3817-singleton-init
Closed

Fix #3817: avoid re-entrant singleton cctor NullReferenceException#4107
mattleibow wants to merge 1 commit into
mainfrom
mattleibow/fix-3817-singleton-init

Conversation

@mattleibow

@mattleibow mattleibow commented May 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #3817

Root cause

SKTypeface's static constructor built its default typeface by reading the managed singletons SKFontManager.Default.Handle and SKFontStyle.Normal.Handle. Those are SKObject types whose own static constructors participate in the same eager-initialization cascade driven by SKObject.EnsureStaticInstanceAreInitialized().

When SKFontManager or SKFontStyle was the first SkiaSharp type touched in a process, reading them from SKTypeface's cctor re-entered a static constructor that was still running on the current thread. The CLR allows a thread to re-enter a type initializer it is already running, so the read observed a not-yet-assigned (null) singleton field and threw NullReferenceException → surfaced as TypeInitializationException (e.g. on SKFontManager.Default). This is the chronic main CI crash (Windows/Linux .NET Core test jobs).

Fix

Build the default typeface directly from raw native handles (sk_fontmgr_create_default / sk_fontstyle_new / sk_fontmgr_legacy_create_typeface) inside SKTypeface's cctor, in a try/finally that releases them. This severs both reads of the vulnerable managed singletons, so the cctor no longer depends on any other singleton being fully constructed.

  • Behavior-equivalent: SKFontStyle.Normal == sk_fontstyle_new(Normal=400, Normal=5, Upright).
  • Native ref-counting unchanged: sk_fontmgr_create_default returns +1; legacy_create_typeface does not retain the font manager; both temporaries are released in finally.
  • ABI: additive/internal only — no public surface change.

Regression test

Static constructors run once per process, so an in-process xUnit test in an existing assembly can't reproduce "type X touched first" — something always touches SKObject before the test body runs.

Added a small dedicated test assembly, SkiaSharp.Tests.SingletonInit.Console, modeled on SkiaSharp.Vulkan.Tests.Console. dotnet test launches a separate test-host process per assembly, so this assembly's single [Fact] is the first SkiaSharp code touched in its process. The test accesses SKFontManager.Default first and asserts it — plus SKFontStyle.Normal.Handle and SKTypeface.Default.Handle — are non-zero, reproducing the exact #3817 first-touch ordering with no child processes or copy targets.

The assembly is wired into the cake test runners (tests-netcore.cake for net10.0, tests-netfx.cake for net48) and tests/SkiaSharp.Tests.Console.sln, so it builds and runs on every CI leg including .NET Framework.

Verification

  • Reproduced the NRE deterministically on clean main: with the fix reverted, the new test fails with exit code 1 and the exact chain SKFontManager..cctor → SKObject..cctor → SKTypeface.EnsureStaticInstanceAreInitialized() → SKTypeface..cctor. With the fix it passes, exit code 0.
  • New assembly (net10.0): Passed 1/1, process exit code 0.
  • The reverted console test project rebuilds clean; full console suite remains green.

@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

📦 Try the packages from this PR

Warning

Do not run these scripts without first reviewing the code in this PR.

Step 1 — Download the packages

bash / macOS / Linux:

curl -fsSL https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.sh | bash -s -- 4107

PowerShell / Windows:

iex "& { $(irm https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.ps1) } 4107"

Step 2 — Add the local NuGet source

dotnet nuget add source ~/.skiasharp/hives/pr-4107/packages --name skiasharp-pr-4107
More options
Option Description
--successful-only / -SuccessfulOnly Only use successful builds
--force / -Force Overwrite previously downloaded packages
--list / -List List available artifacts without downloading
--build-id ID / -BuildId ID Download from a specific build

Or download manually from Azure Pipelines — look for the nuget artifact on the build for this PR.

Remove the source when you're done:

dotnet nuget remove source skiasharp-pr-4107

@mattleibow
mattleibow changed the base branch from dev/issue-3817-fix-singleton-init to main May 30, 2026 19:24
The SKObject static constructor eagerly initializes an ordered cascade of
singletons (SKColorSpace, SKData, SKFontManager, SKTypeface, ...). The
SKTypeface static constructor in that cascade read the managed singletons
SKFontManager.Default and SKFontStyle.Normal. When SKFontManager (or
SKFontStyle) was the first SkiaSharp type touched in a process, the CLR
permits same-thread cctor re-entry and observed a not-yet-assigned null
singleton, producing a NullReferenceException surfaced as a
TypeInitializationException.

Fix: build the default typeface in the SKTypeface static constructor from
raw native handles (sk_fontmgr_create_default / sk_fontstyle_new /
sk_fontmgr_legacy_create_typeface) in a try/finally, severing both managed
singleton reads. This is additive and ABI-safe. Native temporaries are
released in finally.

Add a dedicated regression test assembly (SkiaSharp.Tests.SingletonInit.Console)
with a single test that touches SKFontManager.Default as the first SkiaSharp
type in the process. Because static constructors run once per process and
'dotnet test' launches a separate test host per assembly, this reliably
reproduces the first-touch ordering with no child processes or copy targets.
Wired into the netcore and netfx cake test runners and the solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow
mattleibow force-pushed the mattleibow/fix-3817-singleton-init branch from cad9149 to 5ee76e9 Compare May 30, 2026 19:47
@github-actions

Copy link
Copy Markdown
Contributor

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Site
🔗 View Staging Docs
🔗 View Staging Gallery (Blazor)
🔗 View Staging Gallery (Uno Platform)
🔗 View Staging SkiaFiddle

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

mattleibow added a commit that referenced this pull request Jun 2, 2026
…#4107)

Adds the dedicated SkiaSharp.Tests.SingletonInit.Console assembly with a single
[Fact] that touches SKFontManager.Default as the FIRST SkiaSharp type in a fresh
test-host process, guarding the #3817 re-entrant static-cctor regression. Wires it
into tests-netcore.cake, tests-netfx.cake and the console solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow closed this Jun 3, 2026
@mattleibow
mattleibow deleted the mattleibow/fix-3817-singleton-init branch June 3, 2026 21:59
mattleibow added a commit that referenced this pull request Jun 4, 2026
…ixture StaticTypes list

PR #4116 is a diagnostic experiment pairing PR #4107's alternate fix for
#3817 with PR #4080's test suite. 4107 resists public dispose structurally
(SKxxxStatic subclasses override Dispose(bool) as no-op) rather than via
4080's IgnorePublicDispose flag model, so the dispose-protection scaffolding
ported into the binding was unused by production code and only referenced by
test glue.

- HandleDictionary.cs, SKObject.cs: reverted to main (removed the additive
  disposeProtected overloads that nothing in production used).
- GarbageCleanupFixture.cs: restored main's StaticTypes name-list. 4080's
  version keyed assembly-teardown cleanup off IgnorePublicDispose, which the
  static wrappers under 4107 never set, so Assert.Empty(aliveObjects) threw at
  assembly cleanup and xUnit smeared a single teardown failure across ~1900
  already-passing tests (console showed Failed: 1921). Reverting removes the
  smear so CI reflects only the genuine experiment signal.
- Dropped SKHandleDictionaryDisposeProtectedTest.cs and the two SKObjectTest
  cases that depended on the removed overloads.

After this change the suite reports 13 failures (was ~1921): the genuine
behavioral deltas where 4107 does not implement 4080's IgnorePublicDispose
model, plus one pre-existing GC-timing flake (SKDocumentTest.StreamIsNot
CollectedPrematurely, unchanged from main).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request Jun 4, 2026
Each SKxxxStatic subclass protects its singleton structurally (no-op
Dispose(bool) + OwnsHandle=false) but never set the IgnorePublicDispose
flag. The singleton tests from #4080 assert that flag, so they failed
under #4107's protection model. Call PreventPublicDisposal() in each
static subclass constructor to unify both mechanisms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request Jun 4, 2026
…uard

PreventPublicDisposalOnDisposedWrapperThrows (gated on THROW_OBJECT_EXCEPTIONS,
active only in Debug/device builds) asserted that PreventPublicDisposal() throws
InvalidOperationException on a disposed wrapper. That throwing guard only existed
in #4080's production rewrite, which this PR deliberately omits in favor of #4107's
minimal fix. On main's PreventPublicDisposal() (sets IgnorePublicDispose, no throw)
the assertion cannot hold, so the test failed on Mac Catalyst/iOS while passing
everywhere else. Adding the guard back would reintroduce #4080 production behavior
with a real debug-only race in SKFontManager/SKFontStyleSet callers, so the test
is removed instead. Also corrected the now-stale comment on the sibling live-wrapper
test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[BUG] SKFontManager.Default throws an exception

1 participant