fix(interop): resolve native resource leaks in GTK and WebKit interop
All checks were successful
CI / Build (Linux) (push) Successful in 21s

Fix critical memory leaks identified in architecture review: Add signal handler disconnection in WebKitNative (load-changed and script-dialog signals now properly cleaned up), implement GTK idle callback cleanup with automatic removal on completion, add dlclose() calls for WebKit library handles, track GTK signal IDs in GtkSkiaSurfaceWidget for proper disposal. Replace empty catch blocks in GestureManager with logged exception handling. Add WebKitNative.Cleanup() and GtkNative.ClearCallbacks() methods for application shutdown.
This commit is contained in:
2026-03-06 23:14:53 -05:00
parent c5221ba580
commit 3412cb982e
22 changed files with 1208 additions and 50 deletions

View File

@@ -0,0 +1,560 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using FluentAssertions;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform;
using Xunit;
namespace Microsoft.Maui.Controls.Linux.Tests.Handlers;
#region LabelPropertyMappingTests
public class LabelPropertyMappingTests
{
[Fact]
public void Text_NullMapsToEmpty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.Text = null ?? "";
// Assert
label.Text.Should().BeEmpty();
}
[Fact]
public void TextColor_WhenSet_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
var color = Colors.Red;
// Act
label.TextColor = color;
// Assert
label.TextColor.Should().Be(color);
}
[Fact]
public void FontSize_WhenPositive_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.FontSize = 20.0;
// Assert
label.FontSize.Should().Be(20.0);
label.FontSize.Should().BeGreaterThan(0);
}
[Fact]
public void FontFamily_WhenNotEmpty_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.FontFamily = "Roboto";
// Assert
label.FontFamily.Should().Be("Roboto");
label.FontFamily.Should().NotBeEmpty();
}
[Fact]
public void FontAttributes_Bold_SetsCorrectly()
{
// Arrange
var label = new SkiaLabel();
// Act
label.FontAttributes = FontAttributes.Bold;
// Assert
label.FontAttributes.Should().Be(FontAttributes.Bold);
}
[Fact]
public void HorizontalTextAlignment_Center_MapsCorrectly()
{
// Arrange
var label = new SkiaLabel();
// Act
label.HorizontalTextAlignment = TextAlignment.Center;
// Assert
label.HorizontalTextAlignment.Should().Be(TextAlignment.Center);
}
[Fact]
public void VerticalTextAlignment_End_MapsCorrectly()
{
// Arrange
var label = new SkiaLabel();
// Act
label.VerticalTextAlignment = TextAlignment.End;
// Assert
label.VerticalTextAlignment.Should().Be(TextAlignment.End);
}
[Fact]
public void TextDecorations_Underline_SetsCorrectly()
{
// Arrange
var label = new SkiaLabel();
// Act
label.TextDecorations = TextDecorations.Underline;
// Assert
label.TextDecorations.Should().Be(TextDecorations.Underline);
}
[Fact]
public void LineHeight_WhenSet_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.LineHeight = 1.5;
// Assert
label.LineHeight.Should().Be(1.5);
}
[Fact]
public void CharacterSpacing_WhenSet_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.CharacterSpacing = 2.0;
// Assert
label.CharacterSpacing.Should().Be(2.0);
}
[Fact]
public void MaxLines_WhenSet_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
// Act
label.MaxLines = 3;
// Assert
label.MaxLines.Should().Be(3);
}
[Fact]
public void Padding_WhenSet_UpdatesProperty()
{
// Arrange
var label = new SkiaLabel();
var padding = new Thickness(5, 10, 15, 20);
// Act
label.Padding = padding;
// Assert
label.Padding.Left.Should().Be(5);
label.Padding.Top.Should().Be(10);
label.Padding.Right.Should().Be(15);
label.Padding.Bottom.Should().Be(20);
}
}
#endregion
#region EntryPropertyMappingTests
public class EntryPropertyMappingTests
{
[Fact]
public void Text_NullMapsToEmpty()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.Text = null ?? "";
// Assert
entry.Text.Should().BeEmpty();
}
[Fact]
public void Placeholder_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.Placeholder = "Enter text here";
// Assert
entry.Placeholder.Should().Be("Enter text here");
}
[Fact]
public void IsReadOnly_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.IsReadOnly = true;
// Assert
entry.IsReadOnly.Should().BeTrue();
}
[Fact]
public void MaxLength_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.MaxLength = 50;
// Assert
entry.MaxLength.Should().Be(50);
}
[Fact]
public void IsPassword_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.IsPassword = true;
// Assert
entry.IsPassword.Should().BeTrue();
}
[Fact]
public void CursorPosition_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "Hello World";
// Act
entry.CursorPosition = 5;
// Assert
entry.CursorPosition.Should().Be(5);
}
[Fact]
public void SelectionLength_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "Hello World";
// Act
entry.SelectionLength = 5;
// Assert
entry.SelectionLength.Should().Be(5);
}
[Fact]
public void TextColor_WhenSet_UpdatesProperty()
{
// Arrange
var entry = new SkiaEntry();
var color = Colors.Blue;
// Act
entry.TextColor = color;
// Assert
entry.TextColor.Should().Be(color);
}
[Fact]
public void HorizontalTextAlignment_Center_MapsCorrectly()
{
// Arrange
var entry = new SkiaEntry();
// Act
entry.HorizontalTextAlignment = TextAlignment.Center;
// Assert
entry.HorizontalTextAlignment.Should().Be(TextAlignment.Center);
}
}
#endregion
#region ButtonPropertyMappingTests
public class ButtonPropertyMappingTests
{
[Fact]
public void Text_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
// Act
button.Text = "Click Me";
// Assert
button.Text.Should().Be("Click Me");
}
[Fact]
public void TextColor_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
var color = Colors.White;
// Act
button.TextColor = color;
// Assert
button.TextColor.Should().Be(color);
}
[Fact]
public void IsEnabled_WhenFalse_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
// Act
button.IsEnabled = false;
// Assert
button.IsEnabled.Should().BeFalse();
}
[Fact]
public void BorderColor_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
var color = Colors.DarkGray;
// Act
button.BorderColor = color;
// Assert
button.BorderColor.Should().Be(color);
}
[Fact]
public void BorderWidth_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
// Act
button.BorderWidth = 2.0;
// Assert
button.BorderWidth.Should().Be(2.0);
}
[Fact]
public void CornerRadius_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
// Act
button.CornerRadius = 10;
// Assert
button.CornerRadius.Should().Be(10);
}
[Fact]
public void Padding_WhenSet_UpdatesProperty()
{
// Arrange
var button = new SkiaButton();
var padding = new Thickness(8, 4, 8, 4);
// Act
button.Padding = padding;
// Assert
button.Padding.Left.Should().Be(8);
button.Padding.Top.Should().Be(4);
button.Padding.Right.Should().Be(8);
button.Padding.Bottom.Should().Be(4);
}
}
#endregion
#region CheckBoxPropertyMappingTests
public class CheckBoxPropertyMappingTests
{
[Fact]
public void IsChecked_WhenTrue_UpdatesProperty()
{
// Arrange
var checkBox = new SkiaCheckBox();
// Act
checkBox.IsChecked = true;
// Assert
checkBox.IsChecked.Should().BeTrue();
}
[Fact]
public void IsEnabled_WhenFalse_UpdatesProperty()
{
// Arrange
var checkBox = new SkiaCheckBox();
// Act
checkBox.IsEnabled = false;
// Assert
checkBox.IsEnabled.Should().BeFalse();
}
[Fact]
public void Color_WhenSet_UpdatesProperty()
{
// Arrange
var checkBox = new SkiaCheckBox();
var color = Colors.Green;
// Act
checkBox.Color = color;
// Assert
checkBox.Color.Should().Be(color);
}
}
#endregion
#region SliderPropertyMappingTests
public class SliderPropertyMappingTests
{
[Fact]
public void Minimum_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
slider.Maximum = 100.0; // Must set Maximum first since default is 1.0
// Act
slider.Minimum = 10.0;
// Assert
slider.Minimum.Should().Be(10.0);
}
[Fact]
public void Maximum_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
// Act
slider.Maximum = 200.0;
// Assert
slider.Maximum.Should().Be(200.0);
}
[Fact]
public void Value_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
slider.Maximum = 100.0;
// Act
slider.Value = 50.0;
// Assert
slider.Value.Should().Be(50.0);
}
[Fact]
public void MinimumTrackColor_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
var color = Colors.Orange;
// Act
slider.MinimumTrackColor = color;
// Assert
slider.MinimumTrackColor.Should().Be(color);
}
[Fact]
public void MaximumTrackColor_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
var color = Colors.LightGray;
// Act
slider.MaximumTrackColor = color;
// Assert
slider.MaximumTrackColor.Should().Be(color);
}
[Fact]
public void ThumbColor_WhenSet_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
var color = Colors.Purple;
// Act
slider.ThumbColor = color;
// Assert
slider.ThumbColor.Should().Be(color);
}
[Fact]
public void IsEnabled_WhenFalse_UpdatesProperty()
{
// Arrange
var slider = new SkiaSlider();
// Act
slider.IsEnabled = false;
// Assert
slider.IsEnabled.Should().BeFalse();
}
}
#endregion

View File

@@ -0,0 +1,472 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using FluentAssertions;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform;
using Xunit;
namespace Microsoft.Maui.Controls.Linux.Tests.Views;
/// <summary>
/// Edge case and boundary condition tests for SkiaView.
/// Uses concrete SkiaLabel/SkiaButton since SkiaView is abstract.
/// </summary>
public class SkiaViewEdgeCaseTests
{
[Fact]
public void Opacity_ClampedToValidRange()
{
// Arrange
var view = new SkiaLabel();
// Act - set above max
view.Opacity = 1.5f;
// Assert - clamped to 1.0 via coerceValue
view.Opacity.Should().Be(1.0f);
// Act - set below min
view.Opacity = -0.5f;
// Assert - clamped to 0.0
view.Opacity.Should().Be(0.0f);
}
[Fact]
public void Bounds_DefaultIsZero()
{
// Arrange & Act
var view = new SkiaLabel();
// Assert
view.Bounds.Should().Be(new Rect(0, 0, 0, 0));
}
[Fact]
public void IsVisible_WhenFalse_HitTestReturnsNull()
{
// Arrange
var view = new SkiaButton();
view.Bounds = new Rect(0, 0, 100, 100);
view.IsVisible = false;
// Act - hit test at center of bounds
var result = view.HitTest(50, 50);
// Assert
result.Should().BeNull();
}
[Fact]
public void Children_DefaultEmpty()
{
// Arrange & Act
var view = new SkiaLabel();
// Assert
view.Children.Should().BeEmpty();
}
[Fact]
public void Measure_WithZeroSize_ReturnsZero()
{
// Arrange
var view = new SkiaLabel();
// Act
var result = view.Measure(new Size(0, 0));
// Assert - should not throw, and result should have non-negative dimensions
result.Width.Should().BeGreaterThanOrEqualTo(0);
result.Height.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]
public void Arrange_WithNegativeValues_DoesNotThrow()
{
// Arrange
var view = new SkiaLabel();
// Act & Assert - should not throw
var exception = Record.Exception(() => view.Arrange(new Rect(-10, -20, -50, -100)));
exception.Should().BeNull();
}
[Fact]
public void Scale_DefaultIsOne()
{
// Arrange & Act
var view = new SkiaLabel();
// Assert
view.Scale.Should().Be(1.0);
}
[Fact]
public void Rotation_DefaultIsZero()
{
// Arrange & Act
var view = new SkiaLabel();
// Assert
view.Rotation.Should().Be(0.0);
}
[Fact]
public void Margin_DefaultIsZero()
{
// Arrange & Act
var view = new SkiaLabel();
// Assert
view.Margin.Should().Be(default(Thickness));
}
[Fact]
public void Padding_DefaultIsZero()
{
// Arrange & Act - Use SkiaLabel which doesn't override default padding
var view = new SkiaLabel();
// Assert
view.Padding.Should().Be(default(Thickness));
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var view = new SkiaLabel();
// Act & Assert - calling Dispose twice should not throw
var exception = Record.Exception(() =>
{
view.Dispose();
view.Dispose();
});
exception.Should().BeNull();
}
[Fact]
public void InputTransparent_WhenTrue_HitTestReturnsNull()
{
// Arrange
var view = new SkiaButton();
view.Bounds = new Rect(0, 0, 100, 100);
view.InputTransparent = true;
// Act - hit test at center of bounds
var result = view.HitTest(50, 50);
// Assert - InputTransparent causes HitTest to return null
result.Should().BeNull();
}
}
/// <summary>
/// Edge case tests for SkiaLabel.
/// </summary>
public class SkiaLabelEdgeCaseTests
{
[Fact]
public void Text_Null_TreatedAsEmpty()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert - setting null should not throw
var exception = Record.Exception(() => label.Text = null!);
exception.Should().BeNull();
// Text may be null or empty depending on BindableProperty behavior
(label.Text == null || label.Text == string.Empty).Should().BeTrue();
}
[Fact]
public void FontSize_Zero_DoesNotThrow()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert
var exception = Record.Exception(() => label.FontSize = 0);
exception.Should().BeNull();
}
[Fact]
public void FontSize_Negative_DoesNotThrow()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert
var exception = Record.Exception(() => label.FontSize = -1);
exception.Should().BeNull();
}
[Fact]
public void MaxLines_Negative_DoesNotThrow()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert
var exception = Record.Exception(() => label.MaxLines = -1);
exception.Should().BeNull();
}
[Fact]
public void CharacterSpacing_Negative_DoesNotThrow()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert
var exception = Record.Exception(() => label.CharacterSpacing = -5.0);
exception.Should().BeNull();
}
[Fact]
public void Measure_EmptyText_ReturnsSmallSize()
{
// Arrange
var label = new SkiaLabel();
label.Text = string.Empty;
// Act
var size = label.Measure(new Size(500, 500));
// Assert - should return non-negative size (padding + font height minimum)
size.Width.Should().BeGreaterThanOrEqualTo(0);
size.Height.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]
public void LineBreakMode_AllValues_DoNotThrow()
{
// Arrange
var label = new SkiaLabel();
// Act & Assert - iterate all LineBreakMode values
foreach (var mode in Enum.GetValues<LineBreakMode>())
{
var exception = Record.Exception(() => label.LineBreakMode = mode);
exception.Should().BeNull($"setting LineBreakMode to {mode} should not throw");
}
}
}
/// <summary>
/// Edge case tests for SkiaEntry.
/// </summary>
public class SkiaEntryEdgeCaseTests
{
[Fact]
public void Text_WhenReadOnly_CanStillBeSetProgrammatically()
{
// Arrange
var entry = new SkiaEntry();
entry.IsReadOnly = true;
// Act - programmatic set should still work (IsReadOnly only blocks user input)
entry.Text = "hello";
// Assert
entry.Text.Should().Be("hello");
}
[Fact]
public void MaxLength_WhenSet_TruncatesExistingText()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "Hello World";
// Act - MaxLength is a constraint for input, not retroactive truncation
entry.MaxLength = 5;
// Assert - MaxLength property is set; Text may or may not be truncated
// depending on implementation. The property itself should be set.
entry.MaxLength.Should().Be(5);
// Note: In this implementation, MaxLength only constrains new input,
// it does not retroactively truncate existing text.
}
[Fact]
public void CursorPosition_BeyondTextLength_ClampedOrSafe()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "Hi";
// Act & Assert - CursorPosition setter uses Math.Clamp(value, 0, Text.Length)
var exception = Record.Exception(() => entry.CursorPosition = 100);
exception.Should().BeNull();
// Should be clamped to text length
entry.CursorPosition.Should().BeLessThanOrEqualTo(entry.Text.Length);
}
[Fact]
public void SelectionLength_BeyondTextLength_ClampedOrSafe()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "Hi";
// Act & Assert - should not throw
var exception = Record.Exception(() => entry.SelectionLength = 100);
exception.Should().BeNull();
}
[Fact]
public void TextChanged_NotRaisedWhenSameValue()
{
// Arrange
var entry = new SkiaEntry();
entry.Text = "A";
int eventCount = 0;
entry.TextChanged += (s, e) => eventCount++;
// Act - set same value again
entry.Text = "A";
// Assert - BindableProperty does not raise propertyChanged when value is the same
eventCount.Should().Be(0);
}
}
/// <summary>
/// Edge case tests for SkiaSlider.
/// </summary>
public class SkiaSliderEdgeCaseTests
{
[Fact]
public void Value_BelowMinimum_ClampedToMinimum()
{
// Arrange
var slider = new SkiaSlider();
slider.Maximum = 100;
slider.Minimum = 10;
// Act
slider.Value = 5;
// Assert - Value setter uses Math.Clamp(value, Minimum, Maximum)
slider.Value.Should().Be(10);
}
[Fact]
public void Value_AboveMaximum_ClampedToMaximum()
{
// Arrange
var slider = new SkiaSlider();
slider.Minimum = 0;
slider.Maximum = 50;
// Act
slider.Value = 100;
// Assert
slider.Value.Should().Be(50);
}
[Fact]
public void Value_ValueChanged_RaisedOnChange()
{
// Arrange
var slider = new SkiaSlider();
slider.Minimum = 0;
slider.Maximum = 100;
slider.Value = 0;
double? oldValue = null;
double? newValue = null;
slider.ValueChanged += (s, e) =>
{
oldValue = e.OldValue;
newValue = e.NewValue;
};
// Act
slider.Value = 42;
// Assert
oldValue.Should().Be(0);
newValue.Should().Be(42);
}
}
/// <summary>
/// Edge case tests for SkiaStackLayout.
/// </summary>
public class SkiaStackLayoutEdgeCaseTests
{
[Fact]
public void AddChild_NullChild_DoesNotThrow()
{
// Arrange
var layout = new SkiaStackLayout();
// Act & Assert - null child will cause NullReferenceException
// because AddChild accesses child.Parent without null check
var exception = Record.Exception(() => layout.AddChild(null!));
exception.Should().NotBeNull();
exception.Should().BeOfType<NullReferenceException>();
}
[Fact]
public void RemoveChild_NotPresent_DoesNotThrow()
{
// Arrange
var layout = new SkiaStackLayout();
var orphan = new SkiaLabel();
// Act & Assert - removing a child that was never added should not throw
var exception = Record.Exception(() => layout.RemoveChild(orphan));
exception.Should().BeNull();
}
[Fact]
public void Measure_NoChildren_ReturnsZero()
{
// Arrange
var layout = new SkiaStackLayout();
// Act
var size = layout.Measure(new Size(500, 500));
// Assert - empty layout should measure to zero or very small
size.Width.Should().Be(0);
size.Height.Should().Be(0);
}
[Fact]
public void Measure_WithChildren_IncludesSpacing()
{
// Arrange - measure with and without spacing to verify spacing is added
var layoutNoSpacing = new SkiaStackLayout();
layoutNoSpacing.Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical;
layoutNoSpacing.Spacing = 0;
var layoutWithSpacing = new SkiaStackLayout();
layoutWithSpacing.Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical;
layoutWithSpacing.Spacing = 10;
for (int i = 0; i < 3; i++)
{
layoutNoSpacing.AddChild(new SkiaLabel { HeightRequest = 20, WidthRequest = 100 });
layoutWithSpacing.AddChild(new SkiaLabel { HeightRequest = 20, WidthRequest = 100 });
}
// Act
var sizeNoSpacing = layoutNoSpacing.Measure(new Size(500, 500));
var sizeWithSpacing = layoutWithSpacing.Measure(new Size(500, 500));
// Assert - spacing should add 2 * 10 = 20 to the height
sizeWithSpacing.Height.Should().BeGreaterThan(sizeNoSpacing.Height);
(sizeWithSpacing.Height - sizeNoSpacing.Height).Should().BeApproximately(20, 1);
}
}