Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion github-merge-flow-release-11.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"merge-flow-configurations": {
"net11.0": {
"MergeToBranch": "release/11.0.1xx-preview7",
"MergeToBranch": "release/11.0.1xx-rc1",
"ExtraSwitches": "-QuietComments",
"ResetToTargetPaths": "global.json;NuGet.config;eng/Version.Details.xml;eng/Versions.props;eng/common/*"
}
Expand Down
23 changes: 9 additions & 14 deletions src/Controls/src/Core/BindableObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -779,20 +779,15 @@ BindablePropertyContext CreateContext(BindableProperty property)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
BindablePropertyContext GetOrCreateContext(BindableProperty property)
{
#if NETSTANDARD
var context = GetContext(property);
if (context is null)
{
context = CreateContext(property);
_properties.Add(property.InternalId, context);
}
#else
ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_properties, property.InternalId, out var exists);
if (!exists)
{
context = CreateContext(property);
}
#endif
if (_properties.TryGetValue(property.InternalId, out var context))
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
return context;

// Do not use CollectionsMarshal.GetValueRefOrAddDefault: CreateContext invokes
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
// DefaultValueCreator, which is arbitrary user code and may mutate other
// BindableProperties, resizing _properties and invalidating the returned ref.
// See dotnet/maui#36744.
context = CreateContext(property);
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

[moderate] Logic and Correctness Verification — Moving the dictionary insert to after CreateContext correctly fixes the stale-ref corruption in #36744, but it also removes the placeholder slot that previously terminated same-property reentrancy, turning a recoverable failure into an unbounded recursion.

Concrete scenario: a DefaultValueCreator for property P that touches P itself (directly, or indirectly through a converter/handler/OnPropertyChanged callback it triggers).

  • Before: GetValueRefOrAddDefault had already added a slot for P.InternalId, so the reentrant GetOrCreateContext(P) saw exists == true and returned the (null) context. GetValue(P) then fell through the context == null branch at line 171-173 and returned property.DefaultValue; SetValue(P, ...) threw a catchable NullReferenceException.
  • After: _properties still contains no entry for P while CreateContext(P) is executing, so the reentrant call misses again, calls CreateContext(P) again, invokes DefaultValueCreator again — recursing until StackOverflowException, which is uncatchable and terminates the process.

Suggested guard that preserves the #36744 fix: publish a non-null placeholder context (new BindablePropertyContext { Property = property }) into _properties before invoking the creator and populate its Values/Attributes afterwards, or track in-progress InternalIds in a small set and short-circuit reentrant same-property lookups. Either keeps the dictionary free of null slots (the actual #36744 defect) while keeping same-property reentrancy bounded. A regression test for DefaultValueCreator reading its own property would lock this in.

_properties[property.InternalId] = context;
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
return context;
}

Expand Down
55 changes: 55 additions & 0 deletions src/Controls/tests/Core.UnitTests/BindableObjectUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1717,5 +1717,60 @@ public void SpecificityOfHandlers()
Assert.Equal("manual", bindable.GetValue(prop));
}

// Regression test for https://github.com/dotnet/maui/issues/36744
[Fact]
public void DefaultValueCreatorThatMutatesOtherPropertiesDoesNotCorruptPropertyStore()
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
{
var mock = new MockBindable36744();

var triggerValue = mock.GetValue(MockBindable36744.TriggerProperty);
Assert.NotNull(triggerValue);
Assert.Same(triggerValue, mock.GetValue(MockBindable36744.TriggerProperty));
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.

var exception = Record.Exception(() => mock.BindingContext = new object());
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

[minor] Regression Prevention and Test Coverage — The test proves the fix today, but its coverage silently depends on implementation details it never asserts: _properties is constructed as new(4) (BindableObject.cs:43) and the creator performs exactly 15 SetValue calls, which is what forces the Dictionary resize that invalidated the stale ref. If the initial capacity is raised or the creator is trimmed later, no resize occurs, the test still passes, and the regression guard becomes vacuous without anyone noticing.

Suggest adding a direct assertion that the property store itself is intact rather than only asserting "BindingContext set did not throw" — e.g. enumerate mock.GetLocalValueEnumerator() and assert the Trigger property is present with the created value (a null slot makes that enumeration fail loudly), and/or assert the values of P0/P14 round-trip. That way the test fails for the original defect rather than depending on ApplyBindings incidentally dereferencing the null slot.

Assert.Null(exception);
}
}

internal class MockBindable36744 : BindableObject
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
Comment thread
praveenkumarkarunanithi marked this conversation as resolved.
{
public static readonly BindableProperty P0 = BindableProperty.Create("P0", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P1 = BindableProperty.Create("P1", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P2 = BindableProperty.Create("P2", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P3 = BindableProperty.Create("P3", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P4 = BindableProperty.Create("P4", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P5 = BindableProperty.Create("P5", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P6 = BindableProperty.Create("P6", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P7 = BindableProperty.Create("P7", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P8 = BindableProperty.Create("P8", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P9 = BindableProperty.Create("P9", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P10 = BindableProperty.Create("P10", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P11 = BindableProperty.Create("P11", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P12 = BindableProperty.Create("P12", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P13 = BindableProperty.Create("P13", typeof(int), typeof(MockBindable36744), 0);
public static readonly BindableProperty P14 = BindableProperty.Create("P14", typeof(int), typeof(MockBindable36744), 0);

public static readonly BindableProperty TriggerProperty = BindableProperty.Create(
"Trigger", typeof(object), typeof(MockBindable36744), null,
defaultValueCreator: b =>
{
var mb = (MockBindable36744)b;
mb.SetValue(P0, 1);
mb.SetValue(P1, 2);
mb.SetValue(P2, 3);
mb.SetValue(P3, 4);
mb.SetValue(P4, 5);
mb.SetValue(P5, 6);
mb.SetValue(P6, 7);
mb.SetValue(P7, 8);
mb.SetValue(P8, 9);
mb.SetValue(P9, 10);
mb.SetValue(P10, 11);
mb.SetValue(P11, 12);
mb.SetValue(P12, 13);
mb.SetValue(P13, 14);
mb.SetValue(P14, 15);
return new object();
});
}
}
Loading