Update source generator to pass cancellation tokens to compiler phases and passes#12269
Conversation
Rationalize and clean up CodeTarget, DefaultCodeTarget, ComponentCodeTarget, CodeTargetBuilder, DefaultCodeTargetBuilder, and related classes and tests. - Enable nullability - Pull RazorCodeGenerationOptions from RazorCodeDocument.Options - Push functionality into CodeTarget and share between DefaultCodeTarget and ComponentCodeTarget - Use ImmutableArray<ICodeTargetExtension>.Builder rather than ICollection<ICodeTargetExtension> - Clean up DocumentClassifierPassBase a bit - Update tests
- Merge DocumentWriter and DefaultDocumentWriter - Make DocumentWriter a static class with a single static WriteDocument method. - Always retrieve RazorCodeGenerationOptions from the RazorCodeDocument - Change DocumentWriter to only take just a RazorCodeDocument and acquire the DocumentIntermediateNode from it. This avoids the potential pitfall of a RazorCodeDocument and a DocumentIntermediateNode are passed that don't correspond with one another. - Update tests
- Add ThrowIfCancellationRequested() in each Visit* method - Use BuildMethodDeclaration extension method in visitor, and update that method to take MethodParameter instead of a tuple. - Pass CancellationToken from DefaultRazorCSharpLoweringPhase
…yPhase Pass the CancellationToken into the DirectiveVisitor and call ThrowIfCancellationRequested at various points. Note: This change indulges in some long-overdue refactoring of DirectiveVisitor, TagHelperDirectiveVisitor, and ComponentDirectiveVisitor. More data has been push into DirectiveVisitor, and some naming and terminology has been updated to clarify between components and legacy tag helpers.
- Enable nullability throughout - Update IRazorSyntaxTreePass, DefaultDirectiveSyntaxTreePass, and HtmlNodeOptimizationPass to take CancellationTokens
CSharpCodeParser takes an IEnumerable<DirectiveDescriptor>, but it should take an ImmutableArray<DirectiveDescriptor> to avoid boxing RazorParserOptions.Directives. This requires mechanical changes in compiler many tests.
These two collection types have O(log N) look-up instead of O(1) and are best used in scenarios that take advantage of them as persistent data structures. However, that's not really the case here, since they're built up in the constructor and never modified during the lifetime of CSharpCodeParser. This change removes the immutable data structions in favor of Dictionary<TKey, TValue> and introduces a simple KeywordSet abstraction that wraps a FrozenSet<string> or HashSet<string>.
- Remove LINQ code - Use static lambdas to remove closures - Use PooledArrayBuilder<SyntaxToken> rather than a pooled List<SyntaxToken>
- Add CancellationToken to ParserContext - Add CancellationToken to RazorSyntaxTree.Parse - Call CancellationToken.ThrowIfCancellationRequested in HtmlMarkupParser and CSharpCodeParser.
- Pass CancellationToken to RazorProjectEngine.Process when generating decl files - Pass CancellationToken when executing compiler phases. - Don't box ImmutableArray<RazorSourceDocument> unnecessarily when calling GetDeclarationProjectEngine on GetGenerationProjectEngine
|
|
||
| protected IReadOnlyList<ICodeTargetExtension> TargetExtensions { get; private set; } | ||
| protected ImmutableArray<ICodeTargetExtension> TargetExtensions | ||
| => _targetExtensions.NullToEmpty(); |
There was a problem hiding this comment.
I suppose. It's really just for sanity. This property shouldn't be accessed before OnInitialized is called.
|
|
||
| // Act | ||
| processor.ExecutePass<ComponentDocumentClassifierPass>(() => new(Version)); | ||
| processor.ExecutePass<ComponentDocumentClassifierPass>(); |
There was a problem hiding this comment.
Yes, it's overridden from the base RazorProjectEngineTestBase class where it's used to construct the RazorConfiguration used to construct RazorProjectEngine instances in this test infrastructure.
| private readonly Stack<TagTracker> _trackerStack = new(); | ||
| private readonly ErrorSink _errorSink = errorSink; | ||
| private readonly RazorParserOptions _options = options; | ||
| private readonly ISet<TagHelperDescriptor> _usedDescriptors = usedDescriptors ?? new HashSet<TagHelperDescriptor>(); |
There was a problem hiding this comment.
I didn't bother with extra null checks because production code always passes an instance. Tests always ignored the result when it was an out parameter, so I refactored it to not require an instance but didn't change how the code functions.
| internal static class DocumentWriter | ||
| { | ||
| public static DocumentWriter CreateDefault(CodeTarget codeTarget, RazorCodeGenerationOptions options) | ||
| public static RazorCSharpDocument WriteDocument(RazorCodeDocument codeDocument) |
There was a problem hiding this comment.
Nit: having a static class with a single static method is a smell to me. Could this just be a method on RazorCodeDocument instead?
There was a problem hiding this comment.
I agree that the static class with a single static method is small, but I think making it an instance method on RazorCodeDocument sort of breaks the contract between the compiler phases.
Typically, the compiler phases produce some data and add it to the RazorCodeDocument that's being processed. If this became an instance member on RazorCodeDocument, it'd be a bit odd because the contract of is that RazorCodeDocument.GetCSharpDocument() returns null until all of the necessary phases have already run. In order to maintain that contract, the DefaultRazorCSharpLoweringPhase would now do something like this:
var csharpDocument = codeDocument.WriteDocument(cancellationToken);
codeDocument.SetCSharpDocument(csharpDocument);It seems fundamentally strange that there would be a method to produce a new RazorCSharpDocument every time it's called on RazorCodeDocument. Instead, I would expect the contract to be that RazorCodeDocument.GetCSharpDocument() would check to see if there's already a cached RazorCSharpDocument and then write the document if there isn't. However, that could be called before the phases and passes have that are necessary for producing a correct RazorCSharpDocument.
I've rambled a bit (😄 ), but I think the right place to put this method is on DefaultRazoCSharpLoweringPhase itself. That brings in more in line with other phases.
| @@ -70,11 +76,9 @@ public override void VisitDocument(DocumentIntermediateNode node) | |||
| else | |||
| { | |||
| // CodeQL [SM02196] This is supported by the underlying Roslyn APIs and as consumers we must also support it. | |||
There was a problem hiding this comment.
This looks like a CodeQL suppression message, is moving it going to break the suppression?
There was a problem hiding this comment.
Not that I'm aware of. Is there something about CodeQL that links this comment to the file?
...osoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorTagHelperContextDiscoveryPhase.cs
Show resolved
Hide resolved
|
|
||
| namespace Microsoft.AspNetCore.Razor.Language.Legacy; | ||
|
|
||
| internal abstract class KeywordSet |
There was a problem hiding this comment.
Nit: I'm fine with this if you think there is value in it. But HashSet<T> and FrozenSet<T> both implement ISet<T> so you could just use that instead?
There was a problem hiding this comment.
You're right. I went a little overboard with this 😄
There was a problem hiding this comment.
FWIW, I recall that I had wanted to keep the contract a little stronger since ISet<T> has methods for mutation that will mutate in the HashSet<T> case but throw in the FrozenSet<T> case. Since we only need to expose the Contains method, I felt like weakening the contract to ISet<T> was a little too permissive. I wish we could use IReadOnlySet<T>, but that's .NET-only and we still have netstandard2.0. Sigh...
I think I can get the best of both worlds by wrapping ISet<T> or ICollection<T> and making KeywordSet a struct. Is that a happy medium?
...osoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorTagHelperContextDiscoveryPhase.cs
Outdated
Show resolved
Hide resolved
|
|
||
| public override void VisitFieldDeclaration(FieldDeclarationIntermediateNode node) | ||
| { | ||
| _cancellationToken.ThrowIfCancellationRequested(); |
There was a problem hiding this comment.
You mean in VisitMethodDeclaration? Doesn't most Razor code generation occur within a couple of large methods? Wouldn't we want to cancel within those?
There was a problem hiding this comment.
Yep, I was hoping we could just trim the checks at VisitMethodDeclaration.
I guess I just feel that although checking CTs is cheap, doing so this often adds a lot of code, and might end up costing more than it saves if the tokens aren't frequently cancelled.
There was a problem hiding this comment.
My expectation is that checking CTs is far cheaper than what each NodeWriter.Write* call actually does. I do like putting the CT checks here because it means that they don't have to be littered all over the IntermediateNodeWriters.
- Change KeywordSet to a struct - Set capacities of various hash sets and dictionaries in CSharpCodeParser.
This method doesn't really hold its own weight to be on a separate static class and its only used by DefaultRazorCharpLoweringPhase. So, move it there. In addition, move all tests from DocumentWriterTest to DefaultRazorCSharpLoweringPhaseTest.
|
I updated the CI Build, Test Insertion, and Toolset Run links above with the latest changes. |
Key changes:
DocumentWriter.WriteDocument(...)and callThrowIfCancellationRequestedin its visitor.TagHelperParseTreeRewriterand callThrowIfCancellationRequestedin its visitor.DirectiveVisitorin the tag helper context discovery phase to callThrowIfCancellationRequested.CSharpCodeParserandHtmlMarkupParserand callThrowIfCancellationRequestedat a few places during parsing.IRazorSyntaxTreePassimplementations (DefaultDirectiveSyntaxTreePassandHtmlNodeOpitmizationPass) to pass cancellation tokens into their visitors and callThrowIfCancellationRequested.Other changes:
RazorCodeDocumentandDocumentIntermediateNodeto just take the code document and retrieve the node from the code document. This avoids a potential pitfall where a theRazorCodeDocumentandDocumentIntermediateNodepassed in aren't related. (This happens a fair amount in tests.) I didn't enforce this everywhere -- it's an ongoing effort.DirectiveVisitorand its two sub-types for legacy tag helpers and components to clarify terminology and share more code.CodeTargetsystem a bit.DocumentWriterandDefaultDocumentWriter.ImmutableHashSetandImmutableDictionaryin theCSharpCodeParserfor pure look-up scenarios.ImmutableArray<DirectiveDescriptor>toIEnumerable<DirectiveDescriptor>inCSharpCodeParser'sconstructor.TokenizerBackParser.Tip
There are lots of mechanical test updates in this pull request that can largely be skimmed or skipped over.
CI Build: https://dev.azure.com/dnceng/internal/_build/results?buildId=2802919&view=results
Test Insertion: https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/674371
Toolset Run: https://dev.azure.com/dnceng/internal/_build/results?buildId=2802921&view=results