diff --git a/binding/SkiaSharp/SKPath.cs b/binding/SkiaSharp/SKPath.cs index 1f3bcf627d7..edb2a7e09a5 100644 --- a/binding/SkiaSharp/SKPath.cs +++ b/binding/SkiaSharp/SKPath.cs @@ -25,9 +25,17 @@ internal SKPath (IntPtr handle, bool owns) // reads path.Handle directly and P/Invokes with it. If mutations have been batched // into _builder but not yet flushed, base.Handle points at a stale native SkPath. // Flushing in the getter keeps every reader — internal or external — honest. + // + // Skip the flush once disposal has begun. SKPathBuilder is itself an SKObject with + // its own finalizer, so on the finalizer thread it may already have been collected + // (its Handle is IntPtr.Zero, native pointer freed). Touching it here would call + // sk_pathbuilder_detach_path on a null/dangling handle. The pending mutations are + // going to be discarded with the path anyway, and DisposeNative cleans up _builder + // defensively. public override IntPtr Handle { get { - FlushBuilder (); + if (!IsDisposed) + FlushBuilder (); return base.Handle; } protected set => base.Handle = value; diff --git a/tests/Tests/SkiaSharp/SKPathTest.cs b/tests/Tests/SkiaSharp/SKPathTest.cs index db71791bbb0..93d444186a1 100644 --- a/tests/Tests/SkiaSharp/SKPathTest.cs +++ b/tests/Tests/SkiaSharp/SKPathTest.cs @@ -725,5 +725,31 @@ public void TransformInPlaceWhenSrcEqualsDst() } #pragma warning restore CS0618 + + [SkippableFact] + public void PathWithPendingBuilderMutationsSurvivesFinalization() + { + // Reproduces a finalizer-ordering crash: SKPath holds a private SKPathBuilder + // for batched mutations. Both are SKObjects with their own finalizers, and the + // CLR may finalize the builder first. When the path's finalizer then accesses + // path.Handle, the override calls FlushBuilder, which calls + // sk_pathbuilder_detach_path on the builder's already-freed handle. + MakeAndAbandon(); + + CollectGarbage(); + CollectGarbage(); + + // If the bug is present, the second collect crashes the finalizer thread on + // sk_pathbuilder_detach_path(IntPtr.Zero). Reaching here without an unhandled + // native exception means the disposal path skipped FlushBuilder correctly. + + static void MakeAndAbandon() + { + var path = new SKPath(); + path.MoveTo(0, 0); + path.LineTo(10, 10); + // drop both references; the builder is private and goes with it + } + } } }