|
1 | 1 | using System; |
| 2 | +using System.Reflection; |
2 | 3 | using System.Threading.Tasks; |
3 | 4 | using CoreAnimation; |
4 | 5 | using CoreGraphics; |
@@ -132,19 +133,27 @@ await InvokeOnMainThreadAsync(() => |
132 | 133 |
|
133 | 134 | // Verify the mask was created |
134 | 135 | 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. |
148 | 157 | var ex = Record.Exception(() => content.RemoveFromSuperview()); |
149 | 158 | Assert.Null(ex); |
150 | 159 | }); |
|
0 commit comments