Repository: AvaloniaUI/Avalonia

Issue:
stack overflow in binding system
### Describe the bug

We are observing a stack overflow, caused by a loop in the binding code.  Below are two full iterations of the loop that leads to the stack overflow. 

### To Reproduce

This issue is very easy to recreate.  Adding a MaskedTextBox to a page bound to a string that has an initial value that does not conform to the format of the mask (including null or empty string) will immediately cause a stack overflow when the application starts.

### Expected behavior

No stack overflow is expected.

### Avalonia version

nightly build 11.2.999-cibuild0051406-alpha
last known good version is 11.0.13

### OS

macOS

### Additional context


```
   at Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.InpcPropertyAccessor.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.Plugins.DataValidationBase.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.Plugins.ExceptionValidationPlugin+Validator.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.ExpressionNodes.PropertyAccessorNode.WriteValueToSource(System.Object, System.Collections.Generic.IReadOnlyList`1<Avalonia.Data.Core.ExpressionNodes.ExpressionNode>)
   at Avalonia.Data.Core.BindingExpression.WriteValueToSource(System.Object)
   at Avalonia.Data.Core.BindingExpression.OnTargetPropertyChanged(System.Object, Avalonia.AvaloniaPropertyChangedEventArgs)
   at Avalonia.AvaloniaObject.RaisePropertyChanged(Avalonia.AvaloniaProperty`1<System.__Canon>, Avalonia.Data.Optional`1<System.__Canon>, Avalonia.Data.BindingValue`1<System.__Canon>, Avalonia.Data.BindingPriority, Boolean)
   at Avalonia.PropertyStore.EffectiveValue`1.SetAndRaiseCore(Avalonia.PropertyStore.ValueStore, Avalonia.StyledProperty`1<System.__Canon>, System.__Canon, Avalonia.Data.BindingPriority, Boolean, Boolean)
   at Avalonia.PropertyStore.EffectiveValue`1.SetLocalValueAndRaise(Avalonia.PropertyStore.ValueStore, Avalonia.StyledProperty`1<System.__Canon>, System.__Canon)
   at Avalonia.PropertyStore.EffectiveValue`1.SetLocalValueAndRaise(Avalonia.PropertyStore.ValueStore, Avalonia.AvaloniaProperty, System.Object)
   at Avalonia.PropertyStore.ValueStore.SetLocalValue(Avalonia.AvaloniaProperty, System.Object)
   at Avalonia.PropertyStore.ValueStore.Avalonia.Data.Core.IBindingExpressionSink.OnChanged(Avalonia.Data.Core.UntypedBindingExpressionBase, Boolean, Boolean, System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.UntypedBindingExpressionBase.PublishValue(System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.BindingExpression.ConvertAndPublishValue(System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.BindingExpression.OnNodeValueChanged(Int32, System.Object, System.Exception)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object, System.Exception)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object)
   at Avalonia.Data.Core.ExpressionNodes.PropertyAccessorNode.OnValueChanged(System.Object)
   at Avalonia.Data.Core.Plugins.PropertyAccessorBase.PublishValue(System.Object)
   at Avalonia.Data.Core.Plugins.DataValidationBase.InnerValueChanged(System.Object)
   at Avalonia.Data.Core.Plugins.PropertyAccessorBase.PublishValue(System.Object)
   at Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.InpcPropertyAccessor.SendCurrentValue()

Now the loop starts over....
   
   at Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.InpcPropertyAccessor.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.Plugins.DataValidationBase.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.Plugins.ExceptionValidationPlugin+Validator.SetValue(System.Object, Avalonia.Data.BindingPriority)
   at Avalonia.Data.Core.ExpressionNodes.PropertyAccessorNode.WriteValueToSource(System.Object, System.Collections.Generic.IReadOnlyList`1<Avalonia.Data.Core.ExpressionNodes.ExpressionNode>)
   at Avalonia.Data.Core.BindingExpression.WriteValueToSource(System.Object)
   at Avalonia.Data.Core.BindingExpression.OnTargetPropertyChanged(System.Object, Avalonia.AvaloniaPropertyChangedEventArgs)
   at Avalonia.AvaloniaObject.RaisePropertyChanged(Avalonia.AvaloniaProperty`1<System.__Canon>, Avalonia.Data.Optional`1<System.__Canon>, Avalonia.Data.BindingValue`1<System.__Canon>, Avalonia.Data.BindingPriority, Boolean)
   at Avalonia.PropertyStore.EffectiveValue`1.SetAndRaiseCore(Avalonia.PropertyStore.ValueStore, Avalonia.StyledProperty`1<System.__Canon>, System.__Canon, Avalonia.Data.BindingPriority, Boolean, Boolean)
   at Avalonia.PropertyStore.EffectiveValue`1.SetLocalValueAndRaise(Avalonia.PropertyStore.ValueStore, Avalonia.StyledProperty`1<System.__Canon>, System.__Canon)
   at Avalonia.PropertyStore.EffectiveValue`1.SetLocalValueAndRaise(Avalonia.PropertyStore.ValueStore, Avalonia.AvaloniaProperty, System.Object)
   at Avalonia.PropertyStore.ValueStore.SetLocalValue(Avalonia.AvaloniaProperty, System.Object)
   at Avalonia.PropertyStore.ValueStore.Avalonia.Data.Core.IBindingExpressionSink.OnChanged(Avalonia.Data.Core.UntypedBindingExpressionBase, Boolean, Boolean, System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.UntypedBindingExpressionBase.PublishValue(System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.BindingExpression.ConvertAndPublishValue(System.Object, Avalonia.Data.Core.BindingError)
   at Avalonia.Data.Core.BindingExpression.OnNodeValueChanged(Int32, System.Object, System.Exception)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object, System.Exception)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object)
   at Avalonia.Data.Core.ExpressionNodes.ExpressionNode.SetValue(System.Object)
   at Avalonia.Data.Core.ExpressionNodes.PropertyAccessorNode.OnValueChanged(System.Object)
   at Avalonia.Data.Core.Plugins.PropertyAccessorBase.PublishValue(System.Object)
   at Avalonia.Data.Core.Plugins.DataValidationBase.InnerValueChanged(System.Object)
   at Avalonia.Data.Core.Plugins.PropertyAccessorBase.PublishValue(System.Object)
   at Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.InpcPropertyAccessor.SendCurrentValue()
```

The loop is kicked off initially by this statement:

`   at Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.InpcPropertyAccessor.SubscribeCore()
`




Hints:
Interesting. If you disable CompiledBindings for a test, does it disappear?
After adding this to my project file, the result is the same.

`        <AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
`

`MaskedTextBox` has not changed very much, so it is doing what it has always done, which is to refresh the Text property in the `OnTextInput` `OnKeyDown` and `OnPropertyChanged` events.  If I comment out line 443 in MaskedTextBox.cs, the problem goes away, but of course then the control displays nothing:

`SetCurrentValue(TextProperty, provider.ToDisplayString());`

The Binding System went though a major refactor (#13970), so I assume that the issue was introduced there.  The initial `Text` value is being set to null, the `OnPropertyChanged` event fires, causing a refresh to the `Text` property (again), and the cycle seems to repeat.

The docs say the `SetCurrentValue` method is supposed to be used for programmatically setting a bound property, so it makes sense that is the method called during the events.
This issue caught my eye, but it did not help to test without the changes.  I thought I'd mention it here anyway.

**Make bindings react to PropertyChanged even if property hasn't changed** https://github.com/AvaloniaUI/Avalonia/pull/16150
Thanks for testing. I also end up in an infinite cycle with another 3rd party control and can't wrap my head around. So if it was compiled bindings extension, that would have been a good entry point. I'm also certain it has to do with the bindings refractor somehow. 

/cc @grokys will try to see if I can mange to get this into Sandbox to narrow it down as this is also bugging me ^^
@busitech I can't reproduce this with a simple sample :-( Can you provide a minimal sample for me to test?

My ViewModel:
```cs
public partial class MainViewModel: ObservableObject
{
    [ObservableProperty] private string? _name;
}
```

My MainView:
```xml
<MaskedTextBox Text="{Binding Name}" Mask="???" />
```

Yes, here is my test project.

[MaskedTextBoxTest.zip](https://github.com/user-attachments/files/16688845/MaskedTextBoxTest.zip)

Hello @timunie, were you able to reproduce with my sample?
Another side effect of the binding system changes is that the source property of the Binding on the Text property of the MaskedTextBox is being set to the masked value, even before any user input has been made in the control.

At 11.0.13, no changes would be made to the source property until user input occurs.  This would be the correct behavior.
One input necessary to reproduce this issue is to bind the Text property on the MaskedTextBox to an initial value that does not conform to the mask (including null or empty string).
> Hello @timunie, were you able to reproduce with my sample?

Guess what? I was able to reproduce it with your sample attached. I added the relevant parts to Sandbox, and the issue was gone. Can't wrap my head around it yet 🤷 
I am reproducing with the nightly build 11.2.999-cibuild0051406-alpha, and with my sample directly inside the Avalonia project, using the latest commit.  

I produced a patch today that takes care of the writing back of the value during the initial subscription, and that took care of the cycle.
This is a very simple approach to preventing the stack overflow, by preventing the write-back of the value to the source during publish.

[prevent_stack_overflow.patch](https://github.com/user-attachments/files/16707560/prevent_stack_overflow.patch)

👍 could you make a PR of that patch?
- Helps to run unit tests
- easier for the team to feedback and double-check
Today I made a new patch I am more pleased with.  It will be the basis of my PR.
I ran up against this exception as well.  I was using a `MaskedTextBox` in the format of a GUID value, with the exception occurring at init when passing in `string.Empty`

`
<MaskedTextBox AsciiOnly="True" Name="AccountIdTextBox" Mask=">AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" Text="{Binding LicenseActivator.AccountIdString}"/>
`
Exception occurs when:

```
private string _accountIdString = "";
        
public string AccountIdString
{
    get => _accountIdString;
    set => this.RaiseAndSetIfChanged(ref _accountIdString, value);
}
```

No exception when:
```
private string _accountIdString = "4A3E0760-09E5-4D73-B5BB-256ED9F8A83F";
        
public string AccountIdString
{
    get => _accountIdString;
    set => this.RaiseAndSetIfChanged(ref _accountIdString, value);
}
```

And also no exception when using OneWayToSource binding mode.
`
<MaskedTextBox AsciiOnly="True" Name="AccountIdTextBox" Mask=">AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" Text="{Binding LicenseActivator.AccountIdString, Mode=OneWayToSource}"/>
`


@EvanHaklar can you try the linked PR as well with your sample?
Triggering a property change event for unchanged values should not cause any issues. Sounds like the control relies on some specific behavior.

To prevent such issues you usually want to have some internal state that makes sure a computed write back isn't causing an infinite loop.
@Gillibald The control, [MaskedTextBox](https://docs.avaloniaui.net/docs/reference/controls/maskedtextbox), is an Avalonia supplied / maintained control.

As stated in the attached PR, the binding subsystem is sending obsolete change events containing old values, _after_ those values have already been replaced by newer values, and after events have already been sent to the same subscribers with the most current value.  This is the root cause of the endless loop.

The MaskedTextBox control relies on this specific behavior: the absence of obsolete and out of sequence events being sent. I believe this would be the correct behavior of the binding system, and not a shortcoming of the control.

The control was designed to iterate a couple of times recursively at startup when populated with an empty value, but it did not do so infinitely until the binding system went through an overhaul (that project is referenced above).  This caused regression in the MaskedTextBox.

I think the binding subsystem lacks loop detection, and also lacks protection from sending stale values in the event of recursion during publication.  The write back to source seems to be happening more often, which is also unwanted at times.

@timunie Without having a perfect understanding of the entire theory of design behind the binding subsystem, our PR takes a stab at these three major areas, in the hopes of having the additional discussion you mentioned with the team about the items we have identified as root issues.

Patch:
diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs
index abf6f6cb887..635fa3e0960 100644
--- a/src/Avalonia.Base/Data/Core/BindingExpression.cs
+++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs
@@ -512,8 +512,10 @@ private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEven
         Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource);
         Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.PropertyChanged);
 
-        if (e.Property == TargetProperty)
-            WriteValueToSource(e.NewValue);
+        // The value must be read from the target object instead of using the value from the event
+        // because the value may have changed again between the time the event was raised and now.
+        if (e.Property == TargetProperty && TryGetTarget(out var target))
+            WriteValueToSource(target.GetValue(TargetProperty));
     }
 
     private object? ConvertFallback(object? fallback, string fallbackName)


Test patch:
diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs
index d4a799da6fc..9fb52fee388 100644
--- a/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs
+++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs
@@ -667,6 +667,31 @@ public void OneWayToSource_Binding_Does_Not_Override_TwoWay_Binding()
             Assert.Equal("TwoWay", source.Foo);
         }
 
+        [Fact]
+        public void Target_Undoing_Property_Change_During_TwoWay_Binding_Does_Not_Cause_StackOverflow()
+        {
+            var source = new TestStackOverflowViewModel { BoolValue = true };
+            var target = new TwoWayBindingTest();
+
+            source.ResetSetterInvokedCount();
+
+            // The AlwaysFalse property is set to false in the PropertyChanged callback. Ensure
+            // that binding it to an initial `true` value with a two-way binding does not cause a
+            // stack overflow.
+            target.Bind(
+                TwoWayBindingTest.AlwaysFalseProperty,
+                new Binding(nameof(TestStackOverflowViewModel.BoolValue))
+                {
+                    Mode = BindingMode.TwoWay,
+                });
+
+            target.DataContext = source;
+
+            Assert.Equal(1, source.SetterInvokedCount);
+            Assert.False(source.BoolValue);
+            Assert.False(target.AlwaysFalse);
+        }
+
         private class StyledPropertyClass : AvaloniaObject
         {
             public static readonly StyledProperty<double> DoubleValueProperty =
@@ -725,10 +750,24 @@ private class TestStackOverflowViewModel : INotifyPropertyChanged
 
             public const int MaxInvokedCount = 1000;
 
+            private bool _boolValue;
             private double _value;
 
             public event PropertyChangedEventHandler PropertyChanged;
 
+            public bool BoolValue
+            {
+                get => _boolValue;
+                set
+                {
+                    if (_boolValue != value)
+                    {
+                        _boolValue = value;
+                        SetterInvokedCount++;
+                        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BoolValue)));
+                    }
+                } }
+
             public double Value
             {
                 get => _value;
@@ -754,20 +793,38 @@ public double Value
                     }
                 }
             }
+
+            public void ResetSetterInvokedCount() => SetterInvokedCount = 0;
         }
 
         private class TwoWayBindingTest : Control
         {
+            public static readonly StyledProperty<bool> AlwaysFalseProperty =
+                AvaloniaProperty.Register<StyledPropertyClass, bool>(nameof(AlwaysFalse));
             public static readonly StyledProperty<string> TwoWayProperty =
                 AvaloniaProperty.Register<TwoWayBindingTest, string>(
                     "TwoWay",
                     defaultBindingMode: BindingMode.TwoWay);
 
+            public bool AlwaysFalse
+            {
+                get => GetValue(AlwaysFalseProperty);
+                set => SetValue(AlwaysFalseProperty, value);
+            }
+
             public string TwoWay
             {
                 get => GetValue(TwoWayProperty);
                 set => SetValue(TwoWayProperty, value);
             }
+
+            protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+            {
+                base.OnPropertyChanged(change);
+
+                if (change.Property == AlwaysFalseProperty)
+                    SetCurrentValue(AlwaysFalseProperty, false);
+            }
         }
 
         public class Source : INotifyPropertyChanged