Skip to content

[iOS] Fix ObjectDisposedException in ContentView.RemoveContentMask#34680

Open
Oxymoron290 wants to merge 7 commits intodotnet:mainfrom
Oxymoron290:patch-1
Open

[iOS] Fix ObjectDisposedException in ContentView.RemoveContentMask#34680
Oxymoron290 wants to merge 7 commits intodotnet:mainfrom
Oxymoron290:patch-1

Conversation

@Oxymoron290
Copy link
Copy Markdown

@Oxymoron290 Oxymoron290 commented Mar 26, 2026

Description

Fixes an ObjectDisposedException crash in ContentView.RemoveContentMask() on iOS/MacCatalyst when the underlying CAShapeLayer (_contentMask) has already been deallocated by the native runtime during view hierarchy teardown.

The Problem

During view lifecycle teardown, WillRemoveSubview() calls RemoveContentMask(), which calls RemoveFromSuperLayer() on _contentMask. When iOS has already collected the native CAShapeLayer, the managed wrapper's Handle is IntPtr.Zero, calling any method on it throws ObjectDisposedException.

This is a manifestation of the long-standing iOS binding lifecycle issue documented in dotnet/macios#10562, where dealloc of a UIView calls RemoveFromSuperview() for every child while the native object is being deallocated, causing managed code to interact with already-disposed native objects.

Crash stack:

ObjectDisposedException: Cannot access a disposed object.
Object name: 'Microsoft.Maui.Platform.StaticCAShapeLayer'.
   at ObjCRuntime.NativeObjectExtensions.GetNonNullHandle(INativeObject, String)
   at CoreAnimation.CALayer.RemoveFromSuperLayer()
   at Microsoft.Maui.Platform.ContentView.RemoveContentMask()
   at Microsoft.Maui.Platform.ContentView.WillRemoveSubview(UIView)

The Fix

Added a disposal guard in RemoveContentMask() checking _contentMask.Handle != IntPtr.Zero before calling RemoveFromSuperLayer(). This follows the established pattern used throughout the codebase in ViewRenderer.cs, LayoutFactory2.cs, CellRenderer.cs, SkiaGraphicsView.cs, etc.

Related Work

Testing

Added device test RemoveContentMaskDoesNotThrowWhenDisposed that:

  1. Creates a ContentView with an active clip mask
  2. Asserts the mask was created as a CAShapeLayer
  3. Disposes the native handle (simulating iOS deallocation) and asserts Handle == IntPtr.Zero
  4. Verifies RemoveFromSuperview() does not throw

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>
Copilot AI review requested due to automatic review settings March 26, 2026 17:53
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 26, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34680

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34680"

@dotnet-policy-service dotnet-policy-service bot added the community ✨ Community Contribution label Mar 26, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _contentMask layer 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.

Comment on lines +151 to +155
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; }
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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; }

Copilot uses AI. Check for mistakes.
@Oxymoron290 Oxymoron290 marked this pull request as draft March 26, 2026 18:13
- 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>
@Oxymoron290 Oxymoron290 marked this pull request as ready for review March 27, 2026 12:22
@PureWeen
Copy link
Copy Markdown
Member

/azp run maui-pr-uitests, maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

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>
@PureWeen
Copy link
Copy Markdown
Member

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

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>
@PureWeen
Copy link
Copy Markdown
Member

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

Oxymoron290 and others added 3 commits March 30, 2026 10:56
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>
@PureWeen
Copy link
Copy Markdown
Member

/azp run maui-pr-uitests, maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 2 pipeline(s).

@PureWeen
Copy link
Copy Markdown
Member

/backport to release/10.0.1xx-sr3

@github-actions
Copy link
Copy Markdown
Contributor

Started backporting to release/10.0.1xx-sr3 (link to workflow run)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants