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

Clear hoisted locals when disposing iterator and async-iterator state machines #75908

Merged
merged 20 commits into from
Nov 21, 2024

Conversation

jcouv
Copy link
Member

@jcouv jcouv commented Nov 14, 2024

Fixes #75666

Background

We already have logic to clear unmanaged hoisted locals when we exit scopes. But that logic has a number of limitations:

  1. we don't clear top-level locals on normal exit
  2. we don't clear any locals on yield break
  3. we don't clear any locals on early exit (from yield return, ie. foreach (var i in coll) { break; })
  4. we don't clear any locals on throw
static IEnumerable<int> Produce()
{
    {
        string hoistedNestedLocal = "";
        // existing clearing logic doesn't work for:
        // `yield break`
        // `yield return` and the caller breaks `foreach (var i in coll) { break; }`
        // `throw`
    }

    string hoistedTopLevelLocal = "";
    // existing clearing local doesn't work for:
    // normal exit (because a `yield break` is injected, see `FlowAnalysisPass.AppendImplicitReturn`, so the cleanup is added after a `yield break`)
    // `yield break`
    // `yield return` and the caller breaks `foreach (var i in coll) { break; }`
    // `throw`
}

So the current design only works for nested locals on normal exit from their scope.

Change

This PR clears the hoisted fields for all the locals (top-level and nested) in the part of the code that handles disposal: in Dispose method for iterators and at the end of MoveNext for async-iterators.
There is no need to add such clearing for async state machines, as it is non-trivial for a user to get a direct reference to those.

The proposed design fixes scenarios 1, 2, 3, and most of 4 (a throw during disposal still results in uncleared state).
This remains "best effort", as we don't want to add the overhead of an additional try/finally to handle that last scenario.

Look at the diff between commit 1 (baseline) and commit 2 (code change) to see the expectedOutput and IL changes.

@jcouv jcouv self-assigned this Nov 14, 2024
@dotnet-issue-labeler dotnet-issue-labeler bot added the untriaged Issues and PRs which have not yet been triaged by a lead label Nov 14, 2024
@jcouv jcouv force-pushed the clear-dispose branch 3 times, most recently from 3826321 to 9bb50d5 Compare November 14, 2024 20:28
@jcouv jcouv marked this pull request as ready for review November 14, 2024 22:00
@jcouv jcouv requested a review from a team as a code owner November 14, 2024 22:00

protected override BoundStatement GenerateHoistedLocalsCleanup(ImmutableArray<StateMachineFieldSymbol> hoistedLocals)
{
// We need to clean nested hoisted local variables too (not just top-level ones)
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 15, 2024

Choose a reason for hiding this comment

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

We need to clean nested hoisted local variables too (not just top-level ones)

This isn't obvious. It looks like there are two call sites of this method and it is not clear why we want to have this behavior for all of them. Instead of completely ignoring the passed argument, consider adjusting the call sites to construct the right set of locals to clear. We can discuss offline in more details. #Closed

Copy link
Contributor

Choose a reason for hiding this comment

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

This behavior could be a source of bugs in the future, since the method doesn't do what a reasonable dev would expect it to do.

@@ -434,6 +429,50 @@ private void AddVariableCleanup(ArrayBuilder<BoundExpression> cleanup, FieldSymb
}
}

#nullable enable
protected BoundBlock GenerateAllHoistedLocalsCleanup()
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 15, 2024

Choose a reason for hiding this comment

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

GenerateAllHoistedLocalsCleanup

This code looks very different from original implementation of GenerateHoistedLocalsCleanup. Is it based on some other existing code? Also, isn't there a more direct and robust way to enumerate fields used for hoisting? I mean without going through locals, interpreting their replacements, etc. If not, perhaps we can build the list in advance, when the fields are added. #Closed

@@ -160,7 +160,11 @@ internal void GenerateMoveNextAndDispose(BoundStatement body, SynthesizedImpleme
if (rootFrame.knownStates == null)
{
// nothing to finalize
F.CloseMethod(F.Return());
var disposeBody = F.Block(
GenerateAllHoistedLocalsCleanup(),
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 15, 2024

Choose a reason for hiding this comment

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

GenerateAllHoistedLocalsCleanup(),

Are there tests observing effect of this on IL? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, see the diff between commits 1 and 2 for VerifyIL differences.
This change affects all iterator scenarios without try/finally (such as AddVariableCleanup_StringLocal), whereas the change below affects scenarios with try/finally (such as AddVariableCleanup_NestedStringLocal_InTryFinally)

@@ -171,6 +175,7 @@ internal void GenerateMoveNextAndDispose(BoundStatement body, SynthesizedImpleme
ImmutableArray.Create<LocalSymbol>(stateLocal),
F.Assignment(F.Local(stateLocal), F.Field(F.This(), stateField)),
EmitFinallyFrame(rootFrame, state),
GenerateAllHoistedLocalsCleanup(),
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 15, 2024

Choose a reason for hiding this comment

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

GenerateAllHoistedLocalsCleanup(),

Are there tests observing effect of this on IL? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes. See AddVariableCleanup_NestedStringLocal_InTryFinally

@AlekseyTs
Copy link
Contributor

Done with review pass (commit 5)

@jcouv
Copy link
Member Author

jcouv commented Nov 15, 2024

    protected BoundCatchBlock GenerateExceptionHandling(LocalSymbol exceptionLocal, ImmutableArray<StateMachineFieldSymbol> hoistedLocals)

📝 consider renaming or making a local function, consider commenting. This is only for the "master catch" #Closed


Refers to: src/Compilers/CSharp/Portable/Lowering/AsyncRewriter/AsyncMethodToStateMachineRewriter.cs:227 in 386a056. [](commit_id = 386a056, deletion_comment = False)

@jcouv jcouv marked this pull request as ready for review November 18, 2024 17:00
@jcouv jcouv requested a review from AlekseyTs November 19, 2024 01:13
}

protected BoundStatement GenerateHoistedLocalsCleanup(ImmutableArray<StateMachineFieldSymbol> hoistedLocals)
protected virtual BoundStatement GenerateHoistedLocalsCleanupForExit(ImmutableArray<StateMachineFieldSymbol> rootHoistedLocals)
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 19, 2024

Choose a reason for hiding this comment

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

GenerateHoistedLocalsCleanupForExit

Consider removing "HoistedLocals" from the name to completely avoid any confusion that the clean up is limited to the locals in the rootHoistedLocals parameter.

        protected virtual BoundStatement GenerateCleanupForExit(ImmutableArray<StateMachineFieldSymbol> rootHoistedLocals)
``` #Resolved

_fieldsForCleanup = new ArrayBuilder<FieldSymbol>();
foreach (FieldSymbol fieldForCleanup in nonReusableFieldsForCleanup)
{
_fieldsForCleanup.Add(fieldForCleanup);
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 19, 2024

Choose a reason for hiding this comment

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

Add

I think we have an AddRange helper to add all items from nonReusableFieldsForCleanup in one call #Resolved

@@ -428,15 +436,35 @@ internal static bool TryUnwrapBoundStateMachineScope(ref BoundStatement statemen
/// </summary>
private void AddVariableCleanup(ArrayBuilder<BoundExpression> cleanup, FieldSymbol field)
{
if (field.Type.IsManagedTypeNoUseSiteDiagnostics)
var useSiteInfo = new CompoundUseSiteInfo<AssemblySymbol>(F.Diagnostics, F.Compilation.Assembly);
bool isManaged = field.Type.IsManagedType(ref useSiteInfo);
Copy link
Contributor

Choose a reason for hiding this comment

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

bool isManaged = field.Type.IsManagedType(ref useSiteInfo);

This is a second change like that in this PR. Is it a fix for a different issue? It might be better to keep it separate, in case we need to back out one of the changes, etc.

}

[Fact, WorkItem("https://github.com/dotnet/roslyn/issues/75666")]

public void AddVariableCleanup_StringLocal()
Copy link
Contributor

@AlekseyTs AlekseyTs Nov 19, 2024

Choose a reason for hiding this comment

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

This shows as an unnecessary empty line between an attribute and the target unit-test. #Resolved

Copy link
Contributor

@AlekseyTs AlekseyTs left a comment

Choose a reason for hiding this comment

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

LGTM (commit 19)

@jcouv
Copy link
Member Author

jcouv commented Nov 19, 2024

@dotnet/roslyn-compiler for second review. Thanks

@jaredpar jaredpar removed the untriaged Issues and PRs which have not yet been triaged by a lead label Nov 19, 2024
@jaredpar
Copy link
Member

@333fred ptal

@jcouv
Copy link
Member Author

jcouv commented Nov 21, 2024

@333fred for a second review. Thanks

}
finally
{
System.Console.Write(s);
Copy link
Member

@333fred 333fred Nov 21, 2024

Choose a reason for hiding this comment

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

Is there a version of this with a throw in the try? #Resolved

Copy link
Member Author

Choose a reason for hiding this comment

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

No, the only test with a throw in the try is AddVariableCleanup_NestedStringLocal_InTryFinally_WithThrow_EarlyIterationExit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Consider clearing state of iterator state machine when disposal completes
4 participants