Skip to content

Rework the lifecycle of singleton instances#4080

Merged
mattleibow merged 29 commits into
mono:mainfrom
ramezgerges:dev/issue-3817-fix-singleton-init
Jun 10, 2026
Merged

Rework the lifecycle of singleton instances#4080
mattleibow merged 29 commits into
mono:mainfrom
ramezgerges:dev/issue-3817-fix-singleton-init

Conversation

@ramezgerges

Copy link
Copy Markdown
Contributor

Description of Change

Bugs Fixed

API Changes

None.

Behavioral Changes

None.

Required skia PR

None.

PR Checklist

  • Has tests (if omitted, state reason in description)
  • Rebased on top of main at time of PR
  • Merged related skia PRs
  • Changes adhere to coding standard
  • Updated documentation

@github-actions

github-actions Bot commented May 27, 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 -- 4080

PowerShell / Windows:

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

Step 2 — Add the local NuGet source

dotnet nuget add source ~/.skiasharp/hives/pr-4080/packages --name skiasharp-pr-4080
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-4080

@ramezgerges ramezgerges changed the title Dev/issue 3817 fix singleton init Reword the lifecycle of singleton instances May 27, 2026
@ramezgerges ramezgerges changed the title Reword the lifecycle of singleton instances Rework the lifecycle of singleton instances May 27, 2026
@mattleibow

mattleibow commented May 27, 2026

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reworks how SkiaSharp’s “singleton” managed wrappers are created and protected from disposal, primarily to avoid static-constructor ordering cycles (fixing #3817) and to make singleton promotion/disposal race-safe under the HandleDictionary lock.

Changes:

  • Replace eager singleton pre-registration with lazy initialization (per-type cached singletons), plus a module initializer to run the native-library compatibility check at assembly load.
  • Strengthen HandleDictionary / disposal concurrency semantics (recursive lock policy + serialized “dispose-protected” promotion).
  • Update tests and GC fixture logic to reflect the new “dispose-protected” singleton model and ownership-transfer behavior.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Tests/SkiaSharp/SKTypefaceTest.cs Updates ownership-transfer expectations (stream wrapper is disposed after transfer).
tests/Tests/SkiaSharp/SKManagedStreamTest.cs Aligns tests with new transfer/disposal behavior and handle dictionary visibility.
tests/Tests/SkiaSharp/SKColorSpaceTest.cs Updates singleton assertions to check identity + dispose-protection instead of type name.
tests/Tests/SkiaSharp/SKColorFilterTest.cs Same as above for color-filter singletons.
tests/Tests/SkiaSharp/SKCodecTest.cs Updates stream ownership-transfer behavior assertions.
tests/Tests/GarbageCleanupFixture.cs Treats IgnorePublicDispose objects as expected long-lived singletons.
binding/SkiaSharp/SKTypeface.cs Converts Default/Empty to lazy singletons and introduces dispose-protected singleton retrieval.
binding/SkiaSharp/SKPaint.cs Lazily initializes the default font template and marks it dispose-protected.
binding/SkiaSharp/SKObject.cs Adds dispose-protected object factory path; refactors disposal/ownership-transfer mechanics into SKNativeObject.
binding/SkiaSharp/SkiaSharpModuleInitializer.cs Adds [ModuleInitializer] native-library version compatibility check (with polyfill).
binding/SkiaSharp/SKFontStyle.cs Eagerly initializes style singletons as dispose-protected without HandleDictionary registration.
binding/SkiaSharp/SKFontManager.cs Makes Default lazy and updates stream ownership-transfer to native.
binding/SkiaSharp/SKData.cs Makes Empty lazy and dispose-protected.
binding/SkiaSharp/SKColorSpace.cs Makes sRGB/sRGBLinear singletons lazy and dispose-protected.
binding/SkiaSharp/SKColorFilter.cs Makes gamma filter singletons lazy and dispose-protected.
binding/SkiaSharp/SKCodec.cs Updates stream ownership-transfer call site.
binding/SkiaSharp/SKBlender.cs Uses dispose-protected wrappers for blend-mode singleton map.
binding/SkiaSharp/PlatformLock.cs Enables recursive read/write lock behavior to support new disposal locking strategy.
binding/SkiaSharp/HandleDictionary.cs Adds dispose-protected promotion within the HandleDictionary critical section.

Comment thread binding/SkiaSharp/SKPaint.cs Outdated
Comment thread binding/SkiaSharp/SKTypeface.cs
@mattleibow

Copy link
Copy Markdown
Collaborator

Really nice work on this @ramezgerges — the new disposeProtected flow plus making IgnorePublicDispose a one-way latch is a much cleaner mental model than the old SKxxxStatic subclass dance, and the inline comments explaining the race reasoning are genuinely a pleasure to read 🙌

I took a thorough pass and have a few things to flag before merge. None of these change the overall design — they're mostly small fixes plus some test-coverage suggestions.

🔴 Build is currently broken

binding/SkiaSharp/SKPaint.cs:59 has a typo — SKFonxt.DefaultSize should be SKFont.DefaultSize. Confirmed locally:

SKPaint.cs(59,8): error CS0103: The name 'SKFonxt' does not exist in the current context

The new DefaultFont lazy path doesn't have direct coverage, which is why CI didn't catch it earlier. A tiny smoke test like using var p = new SKPaint(); (or exercising Reset()) would lock this in.

🔴 Likely regression in SKTypeface.CreateDefault()

return matched == IntPtr.Zero
    ? empty           // private field, null until Empty property is first read
    : GetObject (matched);

Pre-refactor empty was eagerly initialized in the cctor, so this was always non-null. Now it's lazy, so calling CreateDefault() before anyone has touched SKTypeface.Empty will return null. I think you just want the Empty property here.

🟠 Racy GetObjectPreventPublicDisposal pattern still in a few spots

SKFontManager.MatchFamily (line 73-74), MatchCharacter (177-178), and SKFontStyleSet (42, 53) still do:

var tf = SKTypeface.GetObject(...);
tf?.PreventPublicDisposal();

This is exactly the window the PR closes for singletons: between GetObject returning and PreventPublicDisposal taking the write lock, the wrapper is HD-observable in an unprotected state, and a concurrent Dispose() from another thread that fetched the same cached handle can win. Pre-existing, not something you introduced — but the new GetDisposeProtectedObject helper is the perfect tool to migrate these to while you're in the neighbourhood.

🟠 Undocumented behavioral change

The PR body says "Behavioral Changes: None", but typefaces returned from MatchFamily / MatchCharacter are now IgnorePublicDispose = true, so user tf.Dispose() becomes a silent no-op (cleanup only via finalizer). Worth a one-liner in the PR description / release notes for downstream consumers who deterministically dispose typefaces — they may notice increased native memory pressure.

🟡 Perf thought: Dispose() now takes the global write lock

The new Dispose() holds the HD write lock around the IgnorePublicDispose check + isDisposed CAS. That globally serializes every public Dispose against every reader of the dictionary. For apps that churn lots of short-lived SKPaint/SKPath/SKRect on parallel threads this could become a contention hotspot. A cheap fast-path (volatile read of the flag, CAS, then re-check under the lock only if needed) would let the common non-protected case avoid the writer entirely. Not a blocker — would just be good to benchmark before/after on a busy workload.

🟡 LockRecursionPolicy.SupportsRecursion — does anything actually recurse?

The PlatformLock comment says it's so Dispose's outer lock can re-enter DeregisterHandle via the Handle setter, but in the refactored flow the actual Dispose(bool) body runs outside the held write lock, so I don't see the recursive path anymore. The upgradeable→write transition inside GetOrAddObjectPreventPublicDisposal is a legal upgrade, not recursion. Could you double-check whether SupportsRecursion is still needed? Dropping back to NoRecursion would surface any unintended reentrancy as exceptions in tests.

🟡 Test coverage gaps worth filling

Net-new test coverage feels thin given the size of the refactor — and the unreliable repro from #3817 was dropped in 1dd6cfde. Suggestions in rough priority:

  • Deterministic concurrent-init test for at least one singleton accessor (SKColorSpace.CreateSrgb, SKTypeface.Default, SKFontManager.Default, SKData.Empty, SKColorFilter.CreateSrgbToLinearGamma, the SKBlender static dict). Two threads barriered on a ManualResetEventSlim racing into the accessor, asserting Assert.Same and IgnorePublicDispose == true. That gets you a real regression net for the issue you're fixing.
  • SKPaint construction smoke test — would have caught the typo above.
  • SKTypeface.CreateDefault() when matched == Zero returns Empty — would catch the field-vs-property bug.
  • Concurrent Dispose vs PreventPublicDisposal — the central safety claim of the new design, currently only asserted in prose.
  • GetInstanceNoLocks filters IsDisposed wrappers — new behavior at HandleDictionary.cs:139.
  • RegisterHandle replaces a IsDisposed=true entry — new code path.
  • Singleton init under nested factorySKTypeface.Default's factory transitively triggers SKFontManager.Default and SKFontStyle.Normal; worth a test that exercises that chain from a cold start (which is essentially [BUG] SKFontManager.Default throws an exception #3817 reproed deterministically).

✅ Things I really liked

  • The disposeProtected flag on GetOrAddObject pairing the flag set with the HD critical section is a very nice abstraction.
  • One-way-latch IgnorePublicDispose with a private setter — exactly right.
  • The LazyInitializer + per-singleton lock pattern cleanly breaks the cctor cycle without resurrecting the old EnsureStaticInstanceAreInitialized hack.
  • Eagerly cctor-initing SKFontStyle (which has no transitive singleton deps) — correct shortcut.
  • TransferOwnershipToNative rename + OwnsHandle = false before DisposeInternal closes a real ownership-handoff race, and the stream tests assert the new contract well.
  • Scoping [ModuleInitializer] to just the native-version check is the minimal correct surface.
  • The inline race-reasoning comments in SKObject.cs and HandleDictionary.cs are excellent — please keep them.

Thanks again for tackling this — it's a tricky area and the refactor is heading somewhere much better than where we were. Happy to chat about any of the above if it'd help. 🙏

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@mattleibow

mattleibow commented May 29, 2026

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@mattleibow

Copy link
Copy Markdown
Collaborator

Pushed a follow-up commit (b3f4041) on top of your work — wanted to explain the why so nothing here is surprising. This is all additive hardening/clarity around the lifecycle you reworked; no behavioural change to the locking design itself.

Library (SKObject / HandleDictionary)

  • Mirror-guard leak fix in Dispose() — the diagnostic re-check (THROW_OBJECT_EXCEPTIONS) was throwing after claiming disposal but before Dispose(true), which would leak the native object in diagnostic builds. It now captures the raced state, runs Dispose(true) + SuppressFinalize, and only then throws. Production builds are unaffected (guard is compiled out).
  • Comment accuracy — a few comments overstated the invariant (e.g. implying that observing IsDisposed could only mean a lock-contract violation, which isn't true given the no-lock internal/finalizer dispose paths your own comment documents). Rewrote them to state the precise invariant, and corrected stale wording on isDisposed, IgnorePublicDispose, the disposeProtected branch, and the GetOrAddObject XML doc.
  • Readability — defined "compare-and-swap (CAS)" on first use and spelled out the HD shorthand as HandleDictionary.

Tests

  • Added happy-path + disposed-path coverage for PreventPublicDisposal, and a real production-path test that exercises GetOrAddDisposeProtectedObject against a live existing instance (asserts same-instance return, IgnorePublicDispose, not disposed).
  • Added SKSingletonConcurrencyTest with a deterministic re-entrant "GetObject during dispose" interleaving test (forces the disposed-but-not-yet-deregistered window and asserts the disposed wrapper is filtered, replaced without a double-dispose, and the trailing deregister is a no-op), plus a multi-lock singleton-contention canary across all singleton accessors.

Verified locally: build clean, filtered suite 31 passed / 0 failed / 0 skipped (Debug, with THROW_OBJECT_EXCEPTIONS on).

Separately, I'm drafting a non-blocking follow-up issue about reducing the global-lock contention via lock striping/sharding (with a benchmark plan) — that's purely an optimisation for later and won't hold up this PR. Really nice work on this rework. 🙏

@mattleibow

Copy link
Copy Markdown
Collaborator

Separately tracking some exploratory work on lock-striping / sharding the handle registry over in #4101 (with a draft prototype in #4102) so we don't clutter this PR with it. That investigation is independent and non-blocking — this PR stands on its own.

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@mattleibow

mattleibow commented May 30, 2026

Copy link
Copy Markdown
Collaborator

I've been doing a deep review and found one regression worth flagging before merge.

TL;DR

SKCodec.Create(Stream) (and the latent equivalent in SKFontManager.CreateTypeface(Stream)) now crashes the process with a NullReferenceException when the codec actually decodes from a managed .NET stream. The new test reproduces it deterministically.

Reproduction & evidence

The test just decodes after Create() (the existing CanReadManagedStream only asserts the codec is non-null and never decodes, which is why it didn't catch this):

using var stream = new MemoryStream(File.ReadAllBytes(Path.Combine(PathToImages, "baboon.png")));
using var codec = SKCodec.Create(stream);
Assert.NotNull(codec);
Assert.Equal(SKCodecResult.Success, codec.GetPixels(out var pixels)); // 💥 crashes here

Running it aborts the test host:

Test host process crashed : Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at SkiaSharp.SKManagedStream.OnReadManagedStream(IntPtr buffer, IntPtr size) in SKManagedStream.cs:line 83
   at SkiaSharp.SKManagedStream.OnRead(IntPtr buffer, IntPtr size) in SKManagedStream.cs:line 100
   at SkiaSharp.DelegateProxies.SKManagedStreamReadProxyImplementation(...) in DelegateProxies.stream.cs:line 79

Root cause

This call site changed from RevokeOwnership(codec) to TransferOwnershipToNative():

// SKCodec.cs:260-261
var codec = GetObject (SkiaApi.sk_codec_new_from_stream (stream.Handle, r));
stream.TransferOwnershipToNative ();   // was: stream.RevokeOwnership (codec);

The two are not equivalent for managed-callback streams. On main, RevokeOwnership had two paths:

internal void RevokeOwnership (SKObject newOwner)
{
    OwnsHandle = false;
    IgnorePublicDispose = true;
    if (newOwner == null)
        DisposeInternal ();                       // dispose now
    else
        newOwner.OwnedObjects[Handle] = this;     // keep alive, dispose with the owner
}

For the codec, newOwner != null, so the managed SKManagedStream wrapper was re-parented into codec.OwnedObjects and kept alive until the codec was disposed.

TransferOwnershipToNative() always takes the dispose-now path:

internal void TransferOwnershipToNative ()
{
    OwnsHandle = false;
    DisposeInternal ();   // always disposes immediately
}

Because WrapManagedStream builds the wrapper with disposeManagedStream: true, DisposeManaged() then closes and nulls the underlying .NET stream:

// SKManagedStream.cs:65-67
if (disposeStream && stream != null) {
    stream.Dispose ();
    stream = null;
}

But the native codec reads lazilysk_codec_new_from_stream only stores a pointer; the actual reads happen later (e.g. in GetPixels) via the managed read callback OnReadManagedStream, which now dereferences a null stream → NRE across the native→managed boundary → host crash.

Why it doesn't affect every stream

This only bites streams that native reads back through managed callbacks (SKManagedStream / SKFrontBufferedManagedStream). Native-byte-backed streams like SKMemoryStream are fine, because their bytes live in native memory and don't depend on the managed wrapper surviving — which is why the SKMemoryStream-based tests stay green and this slipped through.

Affected call sites

  • binding/SkiaSharp/SKCodec.cs:261 — confirmed crash (test attached).
  • binding/SkiaSharp/SKFontManager.cs (CreateTypeface(Stream)TransferOwnershipToNative()) — same shape. It currently dodges the crash only because it copies into a memory stream first, but it relies on the same eager-dispose semantics and is worth a hard look / matching test.

Suggested direction (your call)

Restore the "keep the managed wrapper alive until its native owner is disposed" semantics for the callback-stream case — e.g. give the handoff an optional newOwner and re-parent into OwnedObjects (mirroring main's non-null RevokeOwnership path) instead of disposing eagerly, while keeping the always-dispose behavior for native-backed streams. The important invariant: Create(Stream) must not close the caller's stream, since the codec may decode long after Create() returns.

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

mattleibow added a commit that referenced this pull request May 30, 2026
…y-read crash (#4101)

PR #4080 changed TransferOwnershipToNative to EAGERLY dispose the managed
stream wrapper after handing the native object to SKCodec / SKTypeface. For
streams that decode lazily (SKManagedStream / SKFrontBufferedManagedStream
invoke managed read callbacks during a later GetPixels()/table read), that
eager dispose closes the underlying .NET stream and frees the callback
GCHandle, so the subsequent lazy read crashes across the native->managed
boundary and brings down the host.

Restore main's lazy-reparent semantics, with a race fix:

- newOwner == null (native creation failed): dispose eagerly as before
  (no receiver to keep the wrapper alive).
- newOwner != null: re-parent the wrapper as a non-owning owned-child of the
  receiving codec/typeface. It stays alive (rooted in OwnedObjects) and is torn
  down only when the owner is disposed. DisposeUnownedManaged() disposes
  !OwnsHandle children BEFORE the owner frees its native object, so the wrapper
  stops being read before the native object is freed.

Race fix: latch OwnsHandle=false AND PreventPublicDisposal() together under the
same shard write lock that public Dispose() takes before reading
IgnorePublicDispose and claiming the isDisposed CAS. Setting OwnsHandle=false
outside the lock first would let a racing Dispose() observe
IgnorePublicDispose==false, win the claim, skip DisposeNative (OwnsHandle
already false) and run DisposeManaged() — re-creating the crash as a race.

Tests rewritten to lazy semantics + new regression coverage:
- SKCodecTest / SKManagedStreamTest / SKTypefaceTest: the 6 eager-dispose
  assertions now assert the wrapper stays alive/registered until the owner is
  disposed; the underlying .NET stream stays readable (CanRead) until then.
- New: CanDecodeManagedStreamAfterCreate,
  CanDecodeNonSeekableManagedStreamAfterCreate (lazy decode after Create),
  and the SKManagedStream stays-open guard.
- InvalidStreamIsDisposedImmediately (failure path) unchanged.

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

Hunt for concurrency/lifecycle loopholes in the SKObject/HandleDictionary
rework (#4080). Tests only; no production changes.

Drawables DO have a 'native outlives managed' path: a live SKPictureRecorder
retains a native sk_sp<SkDrawable> across the managed wrapper's Dispose(),
whereas the finished SKPicture keeps only a snapshot. New tests:

- NativeOwnerOutlivingManagedDrawableDefersDestroyExactlyOnce: proves managed
  Dispose() only unrefs (fromNative==0 right after dispose) while the recorder
  holds the ref, then the deferred destroy fires (fromNative latches to 1) when
  the last native owner is released; second Dispose() is a safe no-op.
- ConcurrentDisposeOfDrawableAndNativeOwnerIsSafe: 200-iteration Barrier-synced
  stress race of managed Dispose() vs native-owner release.
- RecordedPictureIsIndependentOfManagedDrawableLifetime: rewritten to assert
  only version-independent invariants (InRange(fromNative,0,1), disposed,
  deregistered, picture still renders blue).

Plus 5 SKAbstractManagedStream re-entrancy/destroy-callback tests mirroring the
same fromNative deferred-destroy pattern. All tests use try/finally idempotent
cleanup so GarbageCleanupFixture stays clean on any mid-test throw.

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

Strengthen the SKManagedStream codec ownership-handoff race tests that pin the
LAZY-reparent fix in #4080:

- PublicDisposeWhileCodecReadIsInFlightIsIgnored: deterministic gated race that
  parks the native lazy read inside the .NET Stream.Read callback, fires the
  reparented wrapper's public Dispose while the read is provably in-flight, then
  releases. Reader Task is hoisted and drained in finally so a primary assertion
  failure is never masked by a background decode against a disposed codec.
- PublicDisposeAroundLazyCodecReadIsIgnoredStress / ...TeardownClosesManagedStream
  ExactlyOnce: bounded Task.WaitAll(30s) so a deadlock regression fails fast
  instead of hanging the suite.
- GatedCountingStream now records FirstDisposeThreadId so the teardown-race test
  proves the codec owner-teardown (not the ignored public Dispose) actually closed
  the .NET stream — DisposeCount==1 alone cannot distinguish which path won.

Tests only; no production changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ramezgerges
ramezgerges force-pushed the dev/issue-3817-fix-singleton-init branch from edfad0e to ff18b0f Compare June 1, 2026 12:31
@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

3 similar comments
@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@mattleibow

mattleibow commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

I added test coverage on top of this — tests only, no production changes, and the latest main merged in.

Corner-case tests for the singleton/lifecycle rework (SKHandleDictionary* + helpers)

These drive HandleDictionary directly with a fake, non-owning SKObject (no native allocations):

  • Concurrent construction: callers racing on the same handle get exactly one wrapper, waiters only ever observe a fully-constructed wrapper, and a throwing factory leaves nothing registered so a later call can reconstruct.
  • Dispose-protected promotion: disposeProtected: true sets the one-way IgnorePublicDispose latch, and promote racing a concurrent public Dispose() on the same handle always returns a live, protected wrapper (never torn).
  • Stale-wrapper / address reuse (ABA): a freed handle builds a fresh wrapper, and a stale wrapper finishing cleanup outside the lock never evicts the live replacement. These assert reference identity (AssertDeregistered) rather than "address absent", which is racy under parallel runs.

Managed-stream ownership handoff (SKManagedStreamTest, SKCodecTest, SKTypefaceTest)

When a .NET Stream is passed to SKCodec.Create(Stream) or SKTypeface.FromStream(Stream), SkiaSharp wraps it and reads from it lazily (later, on demand), so the wrapper has to outlive the call. These tests lock down that behaviour so the stream-callback fix can't regress:

  • The stream stays readable after the call and decoding succeeds — including non-seekable streams, and the lazy SVG/PDF write paths (CanDecodeManagedStreamAfterCreate, ...StaysOpenUntilOwnerDisposed).
  • On a failed call (invalid stream) the wrapper is disposed and deregistered immediately instead of leaking.
  • If the stream is disposed while the native side is still reading, or both race at teardown, it is closed exactly once with no double-free (...ClosesManagedStreamExactlyOnce, ConcurrentDisposeOf...).

Re-entrancy contract (SKHandleDictionaryReentrancyTest)

Locks down a factory running inside the upgradeable-read lock: a re-entrant read (GetInstance) is allowed, while a nested create (GetOrAddObject) throws LockRecursionException on non-Windows. The Windows branch (recursive CRITICAL_SECTION, #1383) is skipped since it can't be exercised here.

Singleton initialization

Two pieces:

  • SKSingletonInitTest (main test project): each documented singleton — SKColorSpace.CreateSrgb()/CreateSrgbLinear(), SKData.Empty, SKFontManager.Default, SKTypeface.Default/Empty, SKBlender.CreateBlendMode, the SKFontStyle statics — returns the same instance and carries the IgnorePublicDispose flag; Dispose() on a protected singleton is a no-op; concurrent access returns one instance; CreateDefault() and new SKPaint() don't throw or return null. The [BUG] SKFontManager.Default throws an exception #3817 cold-start cctor cycle is reproduced via an isolated AssemblyLoadContext.
  • A separate single-test assembly (SkiaSharp.Tests.SingletonInit.Console, wired into cake + sln) whose only test touches SKFontManager.Default as the first SkiaSharp type in a fresh dotnet test host process — the genuine process-level [BUG] SKFontManager.Default throws an exception #3817 regression guard. Keeping it to one test per assembly is what preserves the "first type touched" ordering.

These are here to keep the lifecycle behaviour pinned so a future refactor of this central code can't silently regress it.

mattleibow and others added 14 commits June 9, 2026 19:41
Adds a dedicated SkiaSharp.Tests.SingletonInit.Console assembly containing a
single [Fact] that touches SKFontManager.Default as the FIRST SkiaSharp type
in a fresh test-host process. Because static constructors run once per process
and 'dotnet test' launches a separate host per assembly, this reliably exercises
the 'first type touched' ordering that drives the SKObject static-cctor eager
initialization cascade (regression guard for mono#3817). Keeping it isolated in its
own assembly is what guarantees the ordering.

Wires the assembly into tests-netcore.cake, tests-netfx.cake and the console
solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hardens test coverage around the reworked HandleDictionary/SKObject
singleton lifecycle. These cases were discovered while exploring the
lock-striping experiment (mono#4101/mono#4102); the experiment itself adds no
measurable value, but the corner cases it surfaced are worth locking in
against the single-global-lock design in this PR.

Adds (tests only — no production changes):

- SKHandleDictionaryTestHelpers.cs: shared internal fakes
  (FakeNativeObject, GatedCleanupObject) and deterministic concurrency
  helpers. RunConcurrent uses one dedicated Thread per participant with a
  Barrier (the thread pool can't guarantee N simultaneous Parallel.For
  bodies, which deadlocks against Barrier(N)); RunWithTimeout bounds
  dispose-storms so a production deadlock fails cleanly instead of
  hanging. AssertDeregistered<T> asserts a handle no longer resolves to a
  specific wrapper by reference identity, which is safe against native
  address reuse under xUnit's parallel collections (asserting the address
  is simply absent is racy — a parallel test can reallocate a fresh
  wrapper at the same freed pointer).

- SKHandleDictionaryReservationTest.cs: concurrent same-handle
  construction resolves to exactly one wrapper; waiters only observe a
  fully-constructed wrapper; factory failure clears the reservation and
  lets a waiter/retry reconstruct; cross-handle construction in opposite
  order does not deadlock under the single lock.

- SKHandleDictionaryDisposeProtected.cs: dispose-protected construction
  sets the flag; a racing public Dispose() never tears down a
  dispose-protected wrapper.

- SKHandleDictionaryStaleWrapperTest.cs: a stale wrapper deregistering
  after address reuse never evicts the live replacement (GetOrAdd and
  direct-ctor paths, including two stale wrappers vs a third
  replacement).

- SKManagedStreamTest.cs / SKCodecTest.cs: exercise the managed-Stream
  callback path that the existing SKMemoryStream tests don't reach —
  public Dispose() ignored while a codec read is in flight; lazy decode
  after ownership transfer; non-seekable/front-buffered reparenting tears
  down the nested stream exactly once; failed Create closes the stream
  exactly once (seekable and non-seekable); PDF/SVG writer streams stay
  open until the owner is disposed; concurrent dispose of many/same
  managed streams is safe.

Also corrects the InvalidStreamIsDisposedImmediately expectation in three
files: the failed-create path runs RevokeOwnership(null) -> DisposeInternal(),
which genuinely disposes (and deregisters) the wrapper rather than marking
it IgnorePublicDispose. ff18b0f's blanket IsDisposed -> IgnorePublicDispose
rename was right for the success/reparent path but wrong here, where there
is no new owner to protect the wrapper for.

Finally, fixes an intermittent flake in the existing
ManagedStreamIsCollectedWhenTypefaceIsDisposed: it used the same racy
absence assertion (Assert.False(GetInstance(handle, out _))) that fails
when a parallel test reuses the freed native pointer. Switched to the
reference-identity AssertDeregistered<T> helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pin the platform-divergent re-entrancy behaviour of GetOrAddObject, whose
factory runs inside the upgradeable-read lock:

- A factory MAY read the dictionary (GetInstance) from inside the lock; this
  is a legal upgradeable->read downgrade and works on every platform.
- A factory MUST NOT create another registered object (nested GetOrAddObject):
  on non-Windows the lock is ReaderWriterLockSlim(NoRecursion) and throws
  LockRecursionException; on Windows the recursive CRITICAL_SECTION (mono#1383)
  does not throw. The non-Windows assertion is the only one exercised here.

Both behaviours were verified empirically before the asserts were written.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The isolated AssemblyLoadContext cold-start regression test only works on
.NET Core, but the main test project also targets net48 on Windows CI.
Wrap the ALC-specific test and its System.Runtime.Loader using directive in
#if !NETFRAMEWORK so the remaining singleton tests still compile and run on
.NET Framework.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The file was committed without indentation. Re-indent with tabs to match
the rest of the test suite. Whitespace-only change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add IsBrowser skips to tests that need real OS threads or use APIs
unsupported on browser WASM (custom AssemblyLoadContext). Make the
synthetic handle seed 32-bit-safe so x86 Windows/Linux and WASM no
longer hit OverflowException in new IntPtr(long). Root both wrappers
across the thread-id assertion in the racing-teardown stream test so a
stray finalizer cannot flip FirstDisposeThreadId mid-assert.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Route AllSingletonAccessorsAreStableUnderContention through the
RunConcurrent helper so workers are background threads joined against a
shared deadline: a product-side lock-order inversion now fails the test
instead of hanging the suite, and a hung worker cannot keep the host
process alive.

Replace 'throw ex.InnerException;' in the ALC cold-start test with
ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw() to
preserve the original stack trace (the test's whole purpose is to show
where a cctor cycle throws) and avoid an NRE when InnerException is null.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The ported tests used APIs unavailable on .NET Framework 4.8, breaking the
netfx CI leg compile:

- Environment.TickCount64 (RunConcurrent join deadline) does not exist on
  net48; replace with a Stopwatch-based remaining-time computation.
- The static ExceptionDispatchInfo.Throw(ex) overload does not exist on
  net48; replace all uses with Capture(ex).Throw().

Test-only change; no production code touched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… CI)

The iOS and Mac Catalyst legs run under the Mono interpreter, whose
thread-pool starves under load, so tests driven through Task.Run /
Parallel.For timed out there (desktop, .NET Framework and WASM were
clean). Switch those tests to dedicated OS threads and skip the few
techniques the device runtimes genuinely lack.

- Stale-wrapper tests: drive the parked-disposer/creator races through a
  new RunOnThread/ThreadResult helper (dedicated threads) instead of
  Task.Run, keeping the IsBrowser-only skip and gaining device coverage.
- SKManagedStreamTest.ConcurrentDisposeOfManyManagedStreamsIsSafe: use a
  smaller dedicated-thread count on mobile via RunConcurrent.
- SKSingletonInitTest cold-start: skip on Android/iOS/Mac Catalyst too,
  where AssemblyDependencyResolver-based ALC isolation is unavailable.
- AssertDeregistered: look up the base SKObject type to avoid the
  wrong-type THROW_OBJECT_EXCEPTIONS diagnostic on legal handle reuse.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ection

Slow CI agents (Windows netfx, small Azure VMs) could starve the thread-
heavy concurrency tests when run in xUnit's parallel phase, and the exact
native-refcount singleton test could be perturbed by other tests touching
the same process-wide srgb-linear singleton concurrently.

- Extract the SKManagedStream concurrency tests into a dedicated
  SKManagedStreamConcurrencyTest in the serialized collection, and route
  their raw threads through the RunOnThread helper.
- Move SameColorSpaceCreatedDifferentWaysAreTheSameObject (exact refcount
  on the srgb-linear singleton) into SKSingletonInitTest and serialize
  that class; drain finalizers before capturing the baseline so a holder
  from the earlier parallel phase cannot decrement the count mid-test.
- Tag the remaining HandleDictionary threading classes with the
  serialized collection so nothing runs concurrently with them.

Test-only; no production code changes. Full suite: 5710 passed / 12
skipped / 0 failed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A managed wrapper passed as an argument to a native call (e.g. the path in SKPathMeasure(path), data in SKCodec.Create, etc.) could be garbage-collected and finalized DURING that native call, freeing the underlying native object while the in-flight call still read it -> use-after-free / process crash on net48 under GC pressure.

Add GC.KeepAlive(arg) after the consuming native call across the SkiaSharp and HarfBuzzSharp wrappers, and fix the demonstrated SKPathMeasure case. Includes Pr4080UseAfterFreeTest, which forces the race deterministically with a GC/finalizer hammer thread (crashes on the unfixed build, passes when fixed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same use-after-free class as the previous commit, but for the instance receiver: an instance member doing SkiaApi.foo(Handle, ...) can have 'this' garbage-collected and finalized during the native call (after Handle is read), freeing the native object mid-call. Add GC.KeepAlive(this) after the native call in instance members across SkiaSharp/HarfBuzzSharp wrappers.

Reduces the net48 in-flight UAF failures from 1-4/run to 0-1/run. (A residual teardown/finalization crash tied to the dispose-protected singletons remains and is tracked separately.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 1C pass missed the satellite binding assemblies (they use SkottieApi/SceneGraphApi/ResourcesApi, not SkiaApi). SkiaSharp.Skottie.Animation.Version was a confirmed crash: skottie_animation_get_version(Handle, ...) with 'this' collectible mid-call. Add GC.KeepAlive(this) (and wrapper-arg KeepAlives) to their instance native calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The process-lifetime singleton accessors wrapped immortal Skia singletons
with owns:true, so the managed finalizer called SafeUnRef on them at
teardown. Driving the native refcount of an immortal singleton to zero runs
its C++ destructor, which calls free()/delete on non-heap static storage,
producing STATUS_HEAP_CORRUPTION (0xC0000374) on the finalizer thread.

Captured via cdb: ntdll!RtlFreeHeap -> _free_base ->
SkColorSpaceXformColorFilter::~SkColorSpaceXformColorFilter, driven by
SKColorFilter.Finalize -> DisposeNative -> SafeUnRef on the srgbToLinear
static. The native handle resolved to libSkiaSharp!gSingleton (static data),
not the heap.

Affected accessors return SkNoDestructor / function-local-static singletons
that Skia never destroys:
  - SKColorFilter.CreateSrgbToLinearGamma / CreateLinearToSrgbGamma
  - SKColorSpace.CreateSrgb / CreateSrgbLinear
  - SKData.Empty
  - SKBlender mode singletons
  - SKTypeface.Empty

Wrap these with owns:false so Dispose(bool) skips DisposeNative (it gates on
OwnsHandle). The binding leaks its single ref, which is correct: the object
is immortal and never freed by Skia anyway. Mortal dispose-protected
wrappers (font-manager match results, default font manager) keep owns:true.

Full net48 suite: 8/8 runs crashed at teardown before; 0/14 after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ramezgerges
ramezgerges force-pushed the dev/issue-3817-fix-singleton-init branch from 01ddf02 to 14f9a29 Compare June 9, 2026 23:49
@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

The test spins a GC-hammer thread, which throws
System.PlatformNotSupportedException (Arg_PlatformNotSupported) on
single-threaded WASM where Thread.Start is unsupported. Guard with
SkipOnPlatform(IsBrowser, ...) like the sibling concurrency tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@mattleibow
mattleibow merged commit 45cbddf into mono:main Jun 10, 2026
6 checks passed
mattleibow added a commit that referenced this pull request Jun 10, 2026
PR #4080 added a [ModuleInitializer] to run SkiaSharpVersion.CheckNative-
LibraryCompatible after the singleton rework removed the SKObject static
constructor that previously triggered it. A module initializer runs at
assembly load, which forces an eager P/Invoke into libSkiaSharp before a
consumer can configure native-library loading (a DllImportResolver /
NativeLibrary.SetDllImportResolver, or a custom search path). That breaks
the 'configure the native binary first, then use Skia later' pattern.

Move the gate into SkiaApi's static constructor instead. SkiaApi is the
single internal type every P/Invoke flows through, so the CLR guarantees
the check runs before the first sk_* call on every interop configuration
(USE_DELEGATES, USE_LIBRARY_IMPORT, direct DllImport) without running at
assembly load. The re-entrant version probe is safe: the same-thread CLR
type-initializer rule returns the in-progress SkiaApi type, and the only
static state the version path reads is field-initialized before the cctor
body. The body touches no SKObject, so it cannot reintroduce the #3817
static-initializer cycle.

Deletes SkiaSharpModuleInitializer.cs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request Jun 11, 2026
PR #4080 added a [ModuleInitializer] to run SkiaSharpVersion.CheckNative-
LibraryCompatible after the singleton rework removed the SKObject static
constructor that previously triggered it. A module initializer runs at
assembly load, which forces an eager P/Invoke into libSkiaSharp before a
consumer can configure native-library loading (a DllImportResolver /
NativeLibrary.SetDllImportResolver, or a custom search path). That breaks
the 'configure the native binary first, then use Skia later' pattern.

Move the gate into SkiaApi's static constructor instead. SkiaApi is the
single internal type every P/Invoke flows through, so the CLR guarantees
the check runs before the first sk_* call on every interop configuration
(USE_DELEGATES, USE_LIBRARY_IMPORT, direct DllImport) without running at
assembly load. The re-entrant version probe is safe: the same-thread CLR
type-initializer rule returns the in-progress SkiaApi type, and the only
static state the version path reads is field-initialized before the cctor
body. The body touches no SKObject, so it cannot reintroduce the #3817
static-initializer cycle.

Deletes SkiaSharpModuleInitializer.cs.

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

Move native-compat gate from [ModuleInitializer] to SkiaApi cctor (#4133)

Context: #4080

The singleton rework in #4080 removed the SKObject static constructor that
used to trigger SkiaSharpVersion.CheckNativeLibraryCompatible, and re-added
the check as a [ModuleInitializer]. A module initializer runs at assembly
load — before any user code — which forces an eager P/Invoke into
libSkiaSharp before a consumer can configure native-library loading
(registering a DllImportResolver / NativeLibrary.SetDllImportResolver, or
adjusting the search path). That regressed the "set up the native binary
first, then use Skia later" pattern.

Move the gate into SkiaApi's static constructor and delete
SkiaSharpModuleInitializer.cs. SkiaApi is the single internal type through
which every P/Invoke into libSkiaSharp flows, so an explicit static
constructor (which strips the type's beforefieldinit flag) makes the CLR
guarantee the check runs before the first sk_* call — on every interop
configuration (USE_DELEGATES, USE_LIBRARY_IMPORT, direct DllImport) and
every platform — without running at assembly load. This recovers the
friendly supported-version-range message for all consumers, including code
that only touches SKBitmap, SKCanvas, SKMatrix, or SKGraphics.

Why it's safe:

  * Re-entrancy: CheckNativeLibraryCompatible reaches native via
    SkiaApi.sk_version_get_milestone / sk_version_get_increment while the
    cctor is still running. The CLR's same-thread type-initializer rule
    returns the in-progress SkiaApi type without re-running the body, and
    the only static state the version path reads (the USE_DELEGATES
    Lazy<IntPtr> handle and its delegate cache) is a field initializer,
    which runs before the cctor body.
  * No #3817 regression: the cctor body touches only SkiaApi/LibraryLoader,
    never an SKObject, so it cannot reintroduce the static-initializer cycle.
  * Negligible cost: losing beforefieldinit adds a type-init readiness check
    that is masked by the P/Invoke transition itself (~0.35% on CoreCLR,
    statistically zero on Mono/net48).

Builds clean on net48, net9.0, and netstandard2.0; version, singleton-init,
and colorspace tests pass (35/35).

Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ramezgerges
ramezgerges deleted the dev/issue-3817-fix-singleton-init branch June 11, 2026 13:30
@mattleibow mattleibow added this to the 4.148.0-rc.1 milestone Jun 15, 2026
mattleibow added a commit that referenced this pull request Jul 3, 2026
Expand the skill's scan taxonomy from 6 to 12 families, each backed by a
real historical SkiaSharp leak fix (finalizer/collection ordering #3796/#3291,
Clone double-free #2904, disposing native statics #1863/#4080/#1224,
field-not-nulled #1256/#1344, stream/callback/delegate-proxy lifetime
#3589/#2916/#996, allocation-failure #1784/#1642). Broaden Phase 1.3 de-dup to
search by api/type name (real leaks are filed as [BUG], not [memory-leak]) and
add the Blob.FromStream / PR #3473 worked example. Extend the Phase 3.2 fix
table to cover all 12 families. Document two verified, un-filed family-6
candidates surfaced by a model-diverse scan (SKRegion.SpanIterator missing
parent ref; SKPixmap.ExtractSubset/With* not propagating pixelSource).

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

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[BUG] SKFontManager.Default throws an exception

3 participants