Skip to content

Commit 0dcd181

Browse files
Oxymoron290Copilot
andcommitted
Use reflection for deterministic disposed-mask test
Instead of relying on platform-specific Dispose() behavior (which varies depending on native retain counts), create a fresh CAShapeLayer with no native retains, Dispose it (deterministically zeroing Handle), then inject it into the private _contentMask field via reflection. This guarantees Handle == IntPtr.Zero on all platforms and directly tests the production guard in RemoveContentMask(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ee4ad08 commit 0dcd181

1 file changed

Lines changed: 22 additions & 13 deletions

File tree

src/Core/tests/DeviceTests/Handlers/ContentView/ContentViewTests.iOS.cs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Reflection;
23
using System.Threading.Tasks;
34
using CoreAnimation;
45
using CoreGraphics;
@@ -132,19 +133,27 @@ await InvokeOnMainThreadAsync(() =>
132133

133134
// Verify the mask was created
134135
Assert.IsAssignableFrom<CAShapeLayer>(content.Layer.Mask);
135-
var mask = (CAShapeLayer)content.Layer.Mask!;
136-
137-
// Simulate iOS deallocating the layer during view teardown:
138-
// 1. Clear the native reference so the retain count drops
139-
// 2. Dispose the managed wrapper so Handle becomes IntPtr.Zero
140-
// The ContentView's internal _contentMask field still references
141-
// the disposed object, which is exactly the bug scenario.
142-
content.Layer.Mask = null;
143-
mask.Dispose();
144-
Assert.True(mask.Handle == IntPtr.Zero, "Disposed mask should have a zeroed Handle");
145-
146-
// This should not throw ObjectDisposedException —
147-
// RemoveContentMask guards against disposed masks via Handle check.
136+
137+
// Create a deterministically-disposed CAShapeLayer.
138+
// A freshly-created layer with zero native retains is guaranteed
139+
// to have Handle == IntPtr.Zero after Dispose(), regardless of
140+
// platform-specific retain-count or GC timing behavior.
141+
var disposedLayer = new CAShapeLayer();
142+
disposedLayer.Dispose();
143+
Assert.True(disposedLayer.Handle == IntPtr.Zero, "Disposed layer must have a zeroed Handle");
144+
145+
// Use reflection to inject the disposed layer into the private
146+
// _contentMask field, simulating the race condition where iOS
147+
// deallocates the native layer during view teardown while our
148+
// managed field still holds a reference.
149+
var field = typeof(Microsoft.Maui.Platform.ContentView)
150+
.GetField("_contentMask", BindingFlags.NonPublic | BindingFlags.Instance);
151+
Assert.NotNull(field);
152+
field!.SetValue(contentView, disposedLayer);
153+
154+
// RemoveFromSuperview triggers WillRemoveSubview → RemoveContentMask.
155+
// Without the Handle guard, this would throw ObjectDisposedException
156+
// when calling RemoveFromSuperLayer() on the disposed mask.
148157
var ex = Record.Exception(() => content.RemoveFromSuperview());
149158
Assert.Null(ex);
150159
});

0 commit comments

Comments
 (0)