Rework the lifecycle of singleton instances#4080
Conversation
📦 Try the packages from this PRWarning 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 -- 4080PowerShell / 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-4080More options
Or download manually from Azure Pipelines — look for the Remove the source when you're done: dotnet nuget remove source skiasharp-pr-4080 |
|
/azp run |
|
No pipelines are associated with this pull request. |
There was a problem hiding this comment.
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. |
|
Really nice work on this @ramezgerges — the new 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
The new 🔴 Likely regression in
|
|
No pipelines are associated with this pull request. |
|
/azp run |
|
No pipelines are associated with this pull request. |
|
Pushed a follow-up commit ( Library (
Tests
Verified locally: build clean, filtered suite 31 passed / 0 failed / 0 skipped (Debug, with 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. 🙏 |
|
No pipelines are associated with this pull request. |
|
I've been doing a deep review and found one regression worth flagging before merge. TL;DR
Reproduction & evidenceThe test just decodes after 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 hereRunning it aborts the test host: Root causeThis call site changed from // 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 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,
internal void TransferOwnershipToNative ()
{
OwnsHandle = false;
DisposeInternal (); // always disposes immediately
}Because // SKManagedStream.cs:65-67
if (disposeStream && stream != null) {
stream.Dispose ();
stream = null;
}But the native codec reads lazily — Why it doesn't affect every streamThis only bites streams that native reads back through managed callbacks ( Affected call sites
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 |
|
No pipelines are associated with this pull request. |
…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>
…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>
…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>
edfad0e to
ff18b0f
Compare
|
No pipelines are associated with this pull request. |
3 similar comments
|
No pipelines are associated with this pull request. |
|
No pipelines are associated with this pull request. |
|
No pipelines are associated with this pull request. |
|
I added test coverage on top of this — tests only, no production changes, and the latest Corner-case tests for the singleton/lifecycle rework (
|
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>
01ddf02 to
14f9a29
Compare
|
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>
|
No pipelines are associated with this pull request. |
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>
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>
) 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>
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>
Description of Change
Bugs Fixed
API Changes
None.
Behavioral Changes
None.
Required skia PR
None.
PR Checklist