Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

stack overflow in binding system #16746

Closed
busitech opened this issue Aug 21, 2024 · 20 comments · Fixed by #17073
Closed

stack overflow in binding system #16746

busitech opened this issue Aug 21, 2024 · 20 comments · Fixed by #17073

Comments

@busitech
Copy link

busitech commented Aug 21, 2024

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()

@busitech busitech added the bug label Aug 21, 2024
@timunie
Copy link
Contributor

timunie commented Aug 21, 2024

Interesting. If you disable CompiledBindings for a test, does it disappear?

@busitech
Copy link
Author

busitech commented Aug 21, 2024

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.

@busitech
Copy link
Author

busitech commented Aug 21, 2024

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 #16150

@timunie
Copy link
Contributor

timunie commented Aug 21, 2024

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 ^^

@timunie
Copy link
Contributor

timunie commented Aug 21, 2024

@busitech I can't reproduce this with a simple sample :-( Can you provide a minimal sample for me to test?

My ViewModel:

public partial class MainViewModel: ObservableObject
{
    [ObservableProperty] private string? _name;
}

My MainView:

<MaskedTextBox Text="{Binding Name}" Mask="???" />

@busitech
Copy link
Author

Yes, here is my test project.

MaskedTextBoxTest.zip

@busitech
Copy link
Author

Hello @timunie, were you able to reproduce with my sample?

@busitech
Copy link
Author

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.

@busitech
Copy link
Author

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).

@timunie
Copy link
Contributor

timunie commented Aug 22, 2024

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 🤷

@busitech
Copy link
Author

busitech commented Aug 22, 2024

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.

@busitech
Copy link
Author

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

@timunie
Copy link
Contributor

timunie commented Aug 22, 2024

👍 could you make a PR of that patch?

  • Helps to run unit tests
  • easier for the team to feedback and double-check

@busitech
Copy link
Author

Today I made a new patch I am more pleased with. It will be the basis of my PR.

@EvanHaklar
Copy link

EvanHaklar commented Sep 8, 2024

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}"/>

@timunie
Copy link
Contributor

timunie commented Sep 9, 2024

@EvanHaklar can you try the linked PR as well with your sample?

@Gillibald
Copy link
Contributor

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.

@busitech
Copy link
Author

busitech commented Sep 20, 2024

@Gillibald The control, 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.

@timunie
Copy link
Contributor

timunie commented Sep 20, 2024

@busitech looks like we may have a solution. Maybe you can give the PR a try? #17073 (comment)

@busitech
Copy link
Author

@timunie The new PR does fix the stack overflow. We also included changes in our PR which allowed us to revert to the prior behavior of the control, and that change was not included in #17073. I hope to have further discussion with @grokys about the merits of those changes as well. I added a comment with more details here: #16819 (comment)

github-merge-queue bot pushed a commit that referenced this issue Sep 25, 2024
* Add failing test for #16746

* Always read the value from the target object.

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 that occurs in a two-way binding then we end up with a stack overflow.
grokys added a commit that referenced this issue Oct 8, 2024
* Add failing test for #16746

* Always read the value from the target object.

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 that occurs in a two-way binding then we end up with a stack overflow.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
4 participants