Skip to content

Conversation

CyrusNajmabadi
Copy link
Member

@CyrusNajmabadi CyrusNajmabadi commented Jul 28, 2025

Fixes #79587

Found by Jason in our integration tests (which often kick off an SG regen due to intellisense-builds completing) and then quickly trying to validate some other feature which can then collide with teh workspace changed caused by that.

The core issue is that tehre are two effective streams of 'changes' mutating the workspace. The first is some actual user-facing feature in question that attempts to call TryApplyChanges on the workspace to update it with whatever work it is doing. The second is that the workspace's host can call into it to tell it whne a file-save/project-build completes so that SG info for that file/project can be appropriately recomputed.

These operations are fundamentally racey in nature, and a request by a feature to update the workspace could fail if the host-request came in between the time that it captured the original solution and then tried to apply the new solution.

In practice this is unlikely to happen as the user would have to somehow do soemthing like kick off a build in between a feature starting/ending. However, it does happen in integration tests, and it does represent an unpleasant combination of factors that is hard to reason about.

To address this, we now separate out the concepts of a solution-content-change (literally something that changes something about the bitwise representation of the contents of the solution), versus the ambient SG execution versions (which control if we think we should rerun generators or not, regardless of the state of files). We still have the rule that if another content change snuck in that we will fail a TryApplyChanges. But we do allow the ambient SG execution versions to change.

@CyrusNajmabadi CyrusNajmabadi requested a review from a team as a code owner July 28, 2025 16:17
@@ -29,7 +29,7 @@ internal sealed class PdbMatchingSourceTextProvider() : IEventListener, IPdbMatc
private readonly object _guard = new();

private bool _isActive;
private int _baselineSolutionVersion;
private int _baselineSolutionContentVersion;
Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed the generic 'SolutionVersion' concept in many placces to 'SolutionContentVersion' indicating that it revs as the content moves forward.

@@ -53,7 +52,7 @@ internal sealed class LspWorkspaceManager : IDocumentChangeTracker, ILspService
/// workspace).
/// <para/> Access to this is guaranteed to be serial by the <see cref="RequestExecutionQueue{RequestContextType}"/>
/// </summary>
private readonly Dictionary<Workspace, (int? forkedFromVersion, Solution solution)> _cachedLspSolutions = [];
private readonly Dictionary<Workspace, (int? forkedFromVersion, SourceGeneratorExecutionVersionMap? versionMap, Solution solution)> _cachedLspSolutions = [];
Copy link
Member Author

Choose a reason for hiding this comment

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

@dibarbet i wanted to maintain the semantics you had before as they may be important. in this case, we only attempt to remerge if the forked version and the execution version maps are the same.

Copy link
Member

Choose a reason for hiding this comment

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

I think it is correct to only use the cached solution only if the version+execution version are the same.

Because if the workspace execution version changes, we would want to re-fork from the workspace to ensure we have the newly run generators (and not re-use the cached solution where generators did not run).

private static int DetermineNextSolutionStateContentVersion(Solution newSolution, Solution oldSolution)
=> oldSolution.SolutionState == newSolution.SolutionState
? oldSolution.SolutionStateContentVersion // If the solution state is the same, we can keep the same version.
: oldSolution.SolutionStateContentVersion + 1; // Otherwise, increment the version.
Copy link
Member Author

Choose a reason for hiding this comment

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

A core part of the change. Content version only moves forward if hte content actually changes.

Copy link
Member

Choose a reason for hiding this comment

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

To my other comment -- as far as I can tell this should be the only part that's necessary, and the other part isn't necessary at all...

Copy link
Member Author

Choose a reason for hiding this comment

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

you are right :)

return true;
}

void ApplySourceGeneratorExecutionMap()
Copy link
Member Author

Choose a reason for hiding this comment

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

Major part of the change. Docs below describe the core logic we care about here.

@CyrusNajmabadi
Copy link
Member Author

@jasonmalinowski ptal.

@@ -53,7 +52,7 @@ internal sealed class LspWorkspaceManager : IDocumentChangeTracker, ILspService
/// workspace).
/// <para/> Access to this is guaranteed to be serial by the <see cref="RequestExecutionQueue{RequestContextType}"/>
/// </summary>
private readonly Dictionary<Workspace, (int? forkedFromVersion, Solution solution)> _cachedLspSolutions = [];
private readonly Dictionary<Workspace, (int? forkedFromVersion, SourceGeneratorExecutionVersionMap? versionMap, Solution solution)> _cachedLspSolutions = [];
Copy link
Member

Choose a reason for hiding this comment

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

I think it is correct to only use the cached solution only if the version+execution version are the same.

Because if the workspace execution version changes, we would want to re-fork from the workspace to ensure we have the newly run generators (and not re-use the cached solution where generators did not run).

@@ -16,5 +16,5 @@ public static ImmutableArray<DocumentId> GetDocumentIds(this Solution solution,
=> LanguageServer.Extensions.GetDocumentIds(solution, new(documentUri));

public static int GetWorkspaceVersion(this Solution solution)
=> solution.WorkspaceVersion;
=> solution.SolutionStateContentVersion;
Copy link
Member

Choose a reason for hiding this comment

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

cc @davidwengier - I am not sure what you use this for, but you may want to check that this is correct (whether or not you care that this changes if the source generators execute)

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for tagging me. This is just a tie-break for two Razor documents, when their content matches, so anything that moves forward over time is good.

return (workspaceCurrentSolution, IsForked: false);
}

// Step 4: See if we can reuse a previously forked solution.
if (cachedSolution != default && cachedSolution.forkedFromVersion == workspaceCurrentSolution.WorkspaceVersion)
if (cachedSolution != default &&
Copy link
Member

Choose a reason for hiding this comment

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

can you add a test to LspWorkspaceManagerTests to verify this behavior?

Copy link
Member Author

Choose a reason for hiding this comment

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

will do.

Copy link
Member Author

Choose a reason for hiding this comment

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

done.

@CyrusNajmabadi
Copy link
Member Author

Because if the workspace execution version changes, we would want to re-fork from the workspace to ensure we have the newly run generators (and not re-use the cached solution where generators did not run).

WFM.

@CyrusNajmabadi
Copy link
Member Author

@jasonmalinowski ptal.

@CyrusNajmabadi
Copy link
Member Author

@jasonmalinowski ptal.

@@ -1568,6 +1568,78 @@ internal async Task TestNonCompilationLanguage(SourceGeneratorExecutionPreferenc
Assert.NotEqual(initialExecutionMap[noCompilationProject.Id], finalExecutionMap[noCompilationProject.Id]);
}

[Theory, CombinatorialData]
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure why this test is here versus SolutionWithSourceGeneratorTests.cs.

Copy link
Member Author

@CyrusNajmabadi CyrusNajmabadi left a comment

Choose a reason for hiding this comment

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

c

private static int DetermineNextSolutionStateContentVersion(Solution newSolution, Solution oldSolution)
=> oldSolution.SolutionState == newSolution.SolutionState
? oldSolution.SolutionStateContentVersion // If the solution state is the same, we can keep the same version.
: oldSolution.SolutionStateContentVersion + 1; // Otherwise, increment the version.
Copy link
Member Author

Choose a reason for hiding this comment

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

you are right :)

Copy link
Member

@jasonmalinowski jasonmalinowski left a comment

Choose a reason for hiding this comment

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

Approved; question about why the test is in ServiceHubServicesTests still stands but requires no re-review.

@CyrusNajmabadi
Copy link
Member Author

Approved; question about why the test is in ServiceHubServicesTests still stands but requires no re-review.

It was just convenient. But i moved to SolutionWithSourceGenTests as that does make more sense :)

@CyrusNajmabadi CyrusNajmabadi enabled auto-merge July 30, 2025 21:42
@CyrusNajmabadi CyrusNajmabadi merged commit f9090f4 into dotnet:main Jul 30, 2025
24 of 25 checks passed
@CyrusNajmabadi CyrusNajmabadi deleted the sgMerging branch July 30, 2025 22:55
@dotnet-policy-service dotnet-policy-service bot added this to the Next milestone Jul 30, 2025
@RikkiGibson RikkiGibson modified the milestones: Next, 18.0 P1 Aug 19, 2025
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.

ProcessUpdateSourceGeneratorRequestAsync breaks the ability for TryApplyChanges to apply changes
6 participants