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

Fix throw in generator comparer #76769

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

chsienki
Copy link
Contributor

@chsienki chsienki commented Jan 16, 2025

When checking for generator item modification it's possible for an object to throw when calling .Equals(). User supplied comparers are wrapped in a special comparer that catches the exception and wraps it in a UserFunctionException that is used by the generator driver to handle the error and attribute it to the offending generator.

However, if no comparer is supplied the generator uses the default comparer which does not translate exceptions, meaning it can crash the driver.

Whilst fixing this I noticed that we were passing a lot of default comparers around and passing the same comparer to modify calls as we did in construction of the state table, none of which is required. By centralizing the comparer creation and passing we only need to change the default comparer to a wrapped comparer in a single place.

Commit by Commit review makes this simpler to see what / why I did.

Fixes #76765

@dotnet-issue-labeler dotnet-issue-labeler bot added Area-Compilers untriaged Issues and PRs which have not yet been triaged by a lead labels Jan 16, 2025
All nodes either passed in a default comparer or null, which was stored in the node table. Modifications then had the option to supply a seperate comparer to do the modification. In all cases the comparer passed into the modify call was the same as the one passed when creating the table, so we can remove the call from modify and just use the table instance. Rather than each node passing it its own default when it doesn't have a comparer, just pass in null and let the table control creating the default.
@chsienki chsienki force-pushed the fix_throw_in_generator_comparer branch from 330ad77 to 1d077d9 Compare January 16, 2025 00:09
@chsienki chsienki marked this pull request as ready for review January 16, 2025 00:11
@chsienki chsienki requested a review from a team as a code owner January 16, 2025 00:11
@chsienki
Copy link
Contributor Author

@dotnet/roslyn-compiler for review please.

@@ -25,6 +25,8 @@ internal sealed class WrappedUserComparer<T> : IEqualityComparer<T>
{
private readonly IEqualityComparer<T> _inner;

public static WrappedUserComparer<T> Default { get; } = new WrappedUserComparer<T>(EqualityComparer<T>.Default);

public WrappedUserComparer(IEqualityComparer<T> inner)
Copy link
Member

Choose a reason for hiding this comment

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

When you originally wrote this rather than a ctor did you consider the following?

public static WrappedUserComparer Create(IEqualityComparer<T> comparer) => 
  comparer is WrappedUserComparer<T> w ? w : new WrappedUserComparer(comparer);

One concern I had when looking through this code was if we were inadvertently creating nested wraps.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's an internal class and you can only create it via an extension method that doesn't return it, so it shouldn't be possible to actually get an instance of it that can be passed to the ctor as an external user.

@@ -72,7 +72,7 @@ public CombineNode(IIncrementalGeneratorNode<TInput1> input1, IIncrementalGenera
};

var entry = (entry1.Item, input2);
if (state != EntryState.Modified || _comparer is null || !tableBuilder.TryModifyEntry(entry, _comparer, stopwatch.Elapsed, stepInputs, state))
if (state != EntryState.Modified || _comparer is null || !tableBuilder.TryModifyEntry(entry, stopwatch.Elapsed, stepInputs, state))
Copy link
Member

Choose a reason for hiding this comment

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

What does the check _comparer is null serve here? The null case means it's effectively the default comparer so why are we not trying to use that for TryModifyEntry?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's an optimization. If we've already determined that one of the two sides is modified then the default comparer will always find the same result. However if there's a custom comparer it's possible that will override the result and return cached.

@@ -48,7 +48,7 @@ public Builder(PredicateSyntaxStrategy<T> owner, object key, StateTableStore tab
_name = name;
_comparer = comparer;
_key = key;
_filterTable = table.GetStateTableOrEmpty<SyntaxNode>(_owner._filterKey).ToBuilder(stepName: null, trackIncrementalSteps);
_filterTable = table.GetStateTableOrEmpty<SyntaxNode>(_owner._filterKey).ToBuilder(stepName: null, trackIncrementalSteps, equalityComparer: Roslyn.Utilities.ReferenceEqualityComparer.Instance);
Copy link
Member

Choose a reason for hiding this comment

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

T is not constrainted to class so why do we use a reference equality comparer vs. simply the default comparer?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was code written by Cyrus originally, I'm refactoring it to keep it semantically the same.

@@ -46,12 +46,12 @@ public NodeStateTable<TOutput> UpdateStateTable(DriverStateTable.Builder graphSt
this.LogTables(stepName, s_tableType, previousTable, previousTable, sourceTable);
if (graphState.DriverState.TrackIncrementalSteps)
{
return previousTable.CreateCachedTableWithUpdatedSteps(sourceTable, stepName, EqualityComparer<TOutput>.Default);
return previousTable.CreateCachedTableWithUpdatedSteps(sourceTable, stepName, equalityComparer: null);
Copy link
Member

Choose a reason for hiding this comment

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

This creates a new NodeStateTable that drops the comparer from previousTable. Is that intended?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, if it's all cached the comparer would never be used. Later on, if there's a modification the table will be re-created and the real comparer will be passed to it.

@@ -35,7 +35,7 @@ public InputNode(Func<DriverStateTable.Builder, ImmutableArray<T>> getInput, IEq
private InputNode(Func<DriverStateTable.Builder, ImmutableArray<T>> getInput, Action<IIncrementalGeneratorOutputNode>? registerOutput, IEqualityComparer<T>? inputComparer = null, IEqualityComparer<T>? comparer = null, string? name = null)
{
_getInput = getInput;
_comparer = comparer ?? EqualityComparer<T>.Default;
_comparer = comparer;
_inputComparer = inputComparer ?? EqualityComparer<T>.Default;
Copy link
Member

Choose a reason for hiding this comment

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

Should _inputComparer be handled similarly?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, we control the input comparer and all the inputs it compares. If that's throwing it's a real product bug and we don't want to falsely attribute it to a generator.

@chsienki
Copy link
Contributor Author

@dotnet/roslyn-compiler for any further feedback, thanks!

@chsienki chsienki closed this Jan 18, 2025
@chsienki chsienki reopened this Jan 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-Compilers untriaged Issues and PRs which have not yet been triaged by a lead
Projects
None yet
Development

Successfully merging this pull request may close these issues.

NodeStateTable.GetModifiedItemAndState does not guard against a throwing .Equals method
3 participants