[iOS] Fix ObjectDisposedException in ContentView.RemoveContentMask#34680
[iOS] Fix ObjectDisposedException in ContentView.RemoveContentMask#34680Oxymoron290 wants to merge 7 commits intodotnet:mainfrom
Conversation
Check Handle != IntPtr.Zero before calling RemoveFromSuperLayer() on _contentMask to prevent ObjectDisposedException when iOS has already deallocated the underlying CAShapeLayer during view hierarchy teardown. This follows the established disposal guard pattern used throughout the codebase (ViewRenderer.cs, LayoutFactory2.cs, CellRenderer.cs, etc.). Fixes a crash reported in Active Trader Pro on iOS/MacCatalyst where WillRemoveSubview triggers RemoveContentMask after the native layer has been collected. Related: dotnet#30861, dotnet#33818 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34680Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34680" |
|
Hey there @@Oxymoron290! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
There was a problem hiding this comment.
Pull request overview
Fixes an iOS/MacCatalyst ObjectDisposedException when tearing down ContentView by guarding RemoveContentMask() against invoking RemoveFromSuperLayer() on a disposed CAShapeLayer.
Changes:
- Add a native-handle guard before removing the
_contentMasklayer from its superlayer. - Add an iOS device regression test that simulates native mask deallocation and asserts removal does not throw.
- Add minimal clip/shape stubs to force mask creation during the test.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Core/src/Platform/iOS/ContentView.cs | Adds a Handle != IntPtr.Zero guard before calling RemoveFromSuperLayer() on _contentMask. |
| src/Core/tests/DeviceTests/Handlers/ContentView/ContentViewTests.iOS.cs | Adds a device regression test and local stubs to reproduce the disposed-mask scenario. |
src/Core/tests/DeviceTests/Handlers/ContentView/ContentViewTests.iOS.cs
Outdated
Show resolved
Hide resolved
| public Paint Stroke { get; set; } | ||
| public double StrokeThickness { get; set; } = 1; | ||
| public LineCap StrokeLineCap { get; set; } | ||
| public LineJoin StrokeLineJoin { get; set; } | ||
| public float[] StrokeDashPattern { get; set; } |
There was a problem hiding this comment.
This stub introduces non-nullable reference-type properties (Paint Stroke and float[] StrokeDashPattern) without initialization, which can produce nullable-analysis warnings (and may fail builds if warnings are treated as errors). Initialize them to safe defaults or mark them nullable (e.g., Paint? / float[]?) to keep the test project warning-clean.
| public Paint Stroke { get; set; } | |
| public double StrokeThickness { get; set; } = 1; | |
| public LineCap StrokeLineCap { get; set; } | |
| public LineJoin StrokeLineJoin { get; set; } | |
| public float[] StrokeDashPattern { get; set; } | |
| public Paint? Stroke { get; set; } | |
| public double StrokeThickness { get; set; } = 1; | |
| public LineCap StrokeLineCap { get; set; } | |
| public LineJoin StrokeLineJoin { get; set; } | |
| public float[]? StrokeDashPattern { get; set; } |
- Replace conditional mask disposal with Assert.IsType<CAShapeLayer> to fail fast if the mask is not created (avoids false-positive) - Assert Handle == IntPtr.Zero after disposal to verify test setup - Remove customer issue link from test comment - Add reference to upstream dotnet/macios#10562 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run maui-pr-uitests, maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
Assert.IsType<CAShapeLayer> requires an exact type match, but _contentMask is typed as StaticCAShapeLayer (a subclass of CAShapeLayer). This caused the RemoveContentMaskDoesNotThrowWhenDisposed test to fail on iOS and MacCatalyst with: 'Assert.IsType() Failure: Value is not the exact type' Also improve Assert.True to Assert.Equal for better diagnostic output on failure. Fixes: net10.0 iOS/MacCatalyst Helix Tests (Mono) device test failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run maui-pr-devicetests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
…ison Assert.Equal(IntPtr.Zero, mask.Handle) fails on Apple TFMs because mask.Handle returns nint (NativeHandle) and xUnit's overload resolution falls through to Assert.Equal<DateTime>, producing CS1503. Use Assert.True with equality check instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run maui-pr-devicetests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
On real iOS/MacCatalyst devices, calling Dispose() on a CAShapeLayer does not zero its Handle while another native reference (Layer.Mask) still retains it. Clear the native reference first to properly simulate iOS deallocating the layer during view teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
With content.Layer.Mask cleared before Dispose(), the native retain count drops to zero and Handle is properly zeroed. Re-add the assertion to verify the guard condition is actually being tested. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
|
/azp run maui-pr-uitests, maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
|
/backport to release/10.0.1xx-sr3 |
|
Started backporting to |
Description
Fixes an
ObjectDisposedExceptioncrash inContentView.RemoveContentMask()on iOS/MacCatalyst when the underlyingCAShapeLayer(_contentMask) has already been deallocated by the native runtime during view hierarchy teardown.The Problem
During view lifecycle teardown,
WillRemoveSubview()callsRemoveContentMask(), which callsRemoveFromSuperLayer()on_contentMask. When iOS has already collected the nativeCAShapeLayer, the managed wrapper'sHandleisIntPtr.Zero, calling any method on it throwsObjectDisposedException.This is a manifestation of the long-standing iOS binding lifecycle issue documented in dotnet/macios#10562, where
deallocof a UIView callsRemoveFromSuperview()for every child while the native object is being deallocated, causing managed code to interact with already-disposed native objects.Crash stack:
The Fix
Added a disposal guard in
RemoveContentMask()checking_contentMask.Handle != IntPtr.Zerobefore callingRemoveFromSuperLayer(). This follows the established pattern used throughout the codebase inViewRenderer.cs,LayoutFactory2.cs,CellRenderer.cs,SkiaGraphicsView.cs, etc.Related Work
Testing
Added device test
RemoveContentMaskDoesNotThrowWhenDisposedthat:ContentViewwith an active clip maskCAShapeLayerHandle == IntPtr.ZeroRemoveFromSuperview()does not throw