From 84f5d321e828b5105c1ae13292c8a2e865542f57 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:23:02 +0100 Subject: [PATCH 1/2] fix(analyzers): scope TUnit0031 async-void rule to tests and hooks (#6190) AsyncVoidAnalyzer flagged every `async void` method and lambda in any project referencing TUnit, turning legitimate uses (e.g. a System.Timers.Timer.Elapsed event handler) into build errors since the rule's severity is Error. Restrict the rule to its intended scope: an `async void` method is only flagged when it carries a TUnit test or hook attribute, and an `async void` lambda only when it is declared inside such a method. Event handlers and other async void code in test projects are left alone. Adds regression tests covering a non-test async void method, an async void event-handler lambda in a non-test method, and an async void hook. --- .../AsyncVoidAnalyzerTests.cs | 82 +++++++++++++++++++ TUnit.Analyzers/AsyncVoidAnalyzer.cs | 62 ++++++++++++-- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs index 50eb5a29b4..aeecfc55cf 100644 --- a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs @@ -188,4 +188,86 @@ public void Test() .WithLocation(0) ); } + + [Test] + public async Task Async_Void_NonTest_Method_Raises_No_Error() + { + // An async void method that is not a test or hook (e.g. an event handler) + // must not be flagged just because the project references TUnit. See #6190. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + public event EventHandler? Ticked; + + public void Setup() + { + Ticked += OnTicked; + } + + private async void OnTicked(object? sender, EventArgs e) + { + await Task.Delay(1); + } + } + """ + ); + } + + [Test] + public async Task Async_Void_EventHandler_Lambda_In_NonTest_Method_Raises_No_Error() + { + // An async void lambda subscribed to an event from a non-test method is legitimate. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + public event EventHandler? Ticked; + + public void Setup() + { + Ticked += async (sender, e) => + { + await Task.Delay(1); + }; + } + } + """ + ); + } + + [Test] + public async Task Async_Void_Hook_Raises_Error() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + [Before(HookType.Test)] + public async void {|#0:Setup|}() + { + await Task.Delay(1); + } + } + """, + + Verifier + .Diagnostic(Rules.AsyncVoidMethod) + .WithLocation(0) + ); + } } diff --git a/TUnit.Analyzers/AsyncVoidAnalyzer.cs b/TUnit.Analyzers/AsyncVoidAnalyzer.cs index c553c4b8b6..49ec13bfda 100644 --- a/TUnit.Analyzers/AsyncVoidAnalyzer.cs +++ b/TUnit.Analyzers/AsyncVoidAnalyzer.cs @@ -1,8 +1,9 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Extensions; namespace TUnit.Analyzers; @@ -28,12 +29,22 @@ private void AnalyzeMethod(SymbolAnalysisContext context) return; } - if (methodSymbol is { IsAsync: true, ReturnsVoid: true }) + if (methodSymbol is not { IsAsync: true, ReturnsVoid: true }) { - context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, - methodSymbol.Locations.FirstOrDefault()) - ); + return; + } + + // Only flag `async void` on TUnit test methods and hooks. Other `async void` + // methods (e.g. an event handler for System.Timers.Timer.Elapsed) are legitimate + // and must not be flagged just because the project references TUnit. See #6190. + if (!IsTestOrHookMethod(methodSymbol, context.Compilation)) + { + return; } + + context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, + methodSymbol.Locations.FirstOrDefault()) + ); } private void AnalyzeLambda(SyntaxNodeAnalysisContext context) @@ -50,11 +61,44 @@ private void AnalyzeLambda(SyntaxNodeAnalysisContext context) var symbol = context.SemanticModel.GetSymbolInfo(anonymousFunction).Symbol; - if (symbol is IMethodSymbol { ReturnsVoid: true }) + if (symbol is not IMethodSymbol { ReturnsVoid: true }) { - context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, - anonymousFunction.GetLocation()) - ); + return; + } + + // Only flag `async void` lambdas declared inside a TUnit test method or hook, + // so legitimate async void event-handler lambdas elsewhere aren't flagged. See #6190. + var enclosingMethod = GetEnclosingMethod(symbol); + + if (enclosingMethod is null || !IsTestOrHookMethod(enclosingMethod, context.Compilation)) + { + return; } + + context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, + anonymousFunction.GetLocation()) + ); + } + + private static bool IsTestOrHookMethod(IMethodSymbol methodSymbol, Compilation compilation) + { + return methodSymbol.HasTestAttribute(compilation) + || methodSymbol.IsHookMethod(compilation, out _, out _, out _); + } + + private static IMethodSymbol? GetEnclosingMethod(ISymbol? symbol) + { + // Walk out of nested lambdas/anonymous methods to the real enclosing method. + while (symbol is IMethodSymbol method) + { + if (method.MethodKind != MethodKind.AnonymousFunction) + { + return method; + } + + symbol = method.ContainingSymbol; + } + + return null; } } From 2cfdc9316436ad62f00444318e80e24040f930a8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:35:19 +0100 Subject: [PATCH 2/2] fix(analyzers): treat local functions as transparent in async-void scope walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #6244 review: GetEnclosingMethod stopped the walk at the first non-AnonymousFunction symbol, so a local function inside a test method acted as a boundary — an `async void` lambda nested in that local function escaped the TUnit0031 diagnostic. Treat MethodKind.LocalFunction as transparent too, walking up to the real enclosing method. Adds a regression test for a lambda inside a local function inside a [Test]. --- .../AsyncVoidAnalyzerTests.cs | 36 +++++++++++++++++++ TUnit.Analyzers/AsyncVoidAnalyzer.cs | 6 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs index aeecfc55cf..bea4a61b33 100644 --- a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs @@ -246,6 +246,42 @@ public void Setup() ); } + [Test] + public async Task Async_Void_Lambda_In_Local_Function_In_Test_Raises_Error() + { + // A local function is not a boundary: a lambda nested inside one within a test + // is still scoped to the test and must be flagged. See #6190 review. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + [Test] + public void Test() + { + void Helper() + { + Action action = {|#0:async () => + { + await Task.Delay(1); + }|}; + } + + Helper(); + } + } + """, + + Verifier + .Diagnostic(Rules.AsyncVoidMethod) + .WithLocation(0) + ); + } + [Test] public async Task Async_Void_Hook_Raises_Error() { diff --git a/TUnit.Analyzers/AsyncVoidAnalyzer.cs b/TUnit.Analyzers/AsyncVoidAnalyzer.cs index 49ec13bfda..ac1dafab91 100644 --- a/TUnit.Analyzers/AsyncVoidAnalyzer.cs +++ b/TUnit.Analyzers/AsyncVoidAnalyzer.cs @@ -88,10 +88,12 @@ private static bool IsTestOrHookMethod(IMethodSymbol methodSymbol, Compilation c private static IMethodSymbol? GetEnclosingMethod(ISymbol? symbol) { - // Walk out of nested lambdas/anonymous methods to the real enclosing method. + // Walk out of nested lambdas, anonymous methods, and local functions to the + // real enclosing method, so a lambda nested inside a local function of a test + // is still scoped to that test rather than treating the local function as a boundary. while (symbol is IMethodSymbol method) { - if (method.MethodKind != MethodKind.AnonymousFunction) + if (method.MethodKind is not (MethodKind.AnonymousFunction or MethodKind.LocalFunction)) { return method; }