From b5a8fc1ea967b6634bfdb9d99fdb4673dfcf3e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Mon, 4 May 2026 15:08:09 -0400 Subject: [PATCH 1/4] MA0042: check if DisposeAsync is declared directly when runtime type is known When analyzing 'using' statements and declarations: - For direct object creation (new T()) or sealed types, only report a diagnostic if the type itself declares or overrides DisposeAsync. Inheriting a non-async default DisposeAsync (e.g. DbConnection's implementation that just calls Dispose()) is not a meaningful override, so 'await using' would bring no benefit. - For all other expressions (factory methods, non-sealed types), keep the existing full-hierarchy check since the runtime type may be a subclass that properly overrides DisposeAsync. Also removes the now-redundant MemoryStream special case; the general IObjectCreationOperation check already handles it correctly because MemoryStream does not declare DisposeAsync directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...otUseBlockingCallInAsyncContextAnalyzer.cs | 35 ++++- ...nAsyncContextAnalyzer_AsyncContextTests.cs | 147 ++++++++++++++++++ 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs index 9204dfd33..4b0f476b7 100755 --- a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs @@ -74,7 +74,6 @@ public Context(Compilation compilation) ConsoleErrorAndOutSymbols = []; } - MemoryStreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.MemoryStream"); ProcessSymbol = compilation.GetBestTypeByMetadataName("System.Diagnostics.Process"); CancellationTokenSymbol = compilation.GetBestTypeByMetadataName("System.Threading.CancellationToken"); ObsoleteAttributeSymbol = compilation.GetBestTypeByMetadataName("System.ObsoleteAttribute"); @@ -122,7 +121,6 @@ public Context(Compilation compilation) _taskAwaiterLikeSymbols = [.. taskAwaiterLikeSymbols]; } - private ISymbol? MemoryStreamSymbol { get; } private ISymbol? ProcessSymbol { get; } private ISymbol[] ConsoleErrorAndOutSymbols { get; } private INamedTypeSymbol? CancellationTokenSymbol { get; } @@ -454,14 +452,41 @@ private bool HasDisposeAsyncMethod(INamedTypeSymbol symbol) return false; } + private bool HasDisposeAsyncMethodDeclaredDirectly(INamedTypeSymbol symbol) + { + // Only look at members declared directly on the type, not inherited ones. + foreach (var member in symbol.GetMembers("DisposeAsync").OfType()) + { + if (member.Parameters.Length != 0) + continue; + + if (member.IsGenericMethod) + continue; + + if (member.IsStatic) + continue; + + if (!member.ReturnType.IsEqualTo(ValueTaskSymbol)) + continue; + + return true; + } + + return false; + } + private bool CanBeAwaitUsing(IOperation operation) { if (operation.GetActualType() is not INamedTypeSymbol type) return false; - // using var ms = new MemoryStream(); - if (operation is IObjectCreationOperation objectCreationOperation && objectCreationOperation.Type.IsEqualTo(MemoryStreamSymbol)) - return false; + // When the runtime type is statically known — either because the expression is a direct + // object creation (new T()) or because the type is sealed — only report if the type + // itself declares/overrides DisposeAsync. Inheriting a non-async "default" DisposeAsync + // (e.g. DbConnection.DisposeAsync which just calls Dispose()) is not a meaningful override. + var unwrapped = operation.UnwrapImplicitConversionOperations(); + if (unwrapped is IObjectCreationOperation || type.IsSealed) + return HasDisposeAsyncMethodDeclaredDirectly(type); return HasDisposeAsyncMethod(type); } diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs index 815366b91..1ee5def43 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs @@ -2326,4 +2326,151 @@ public async Task A() """) .ValidateAsync(); } + + [Fact] + public async Task UsingNewObjectCreation_InheritedDisposeAsync_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + using System; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var conn = new DerivedConnection(); + } + } + + class BaseConnection : IDisposable + { + public void Dispose() { } + public virtual ValueTask DisposeAsync() => default; + } + + class DerivedConnection : BaseConnection { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_InheritedDisposeAsync_Diagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + using System; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var conn = CreateConnection();|] + } + + private DerivedConnection CreateConnection() => new DerivedConnection(); + } + + class BaseConnection : IDisposable + { + public void Dispose() { } + public virtual ValueTask DisposeAsync() => default; + } + + class DerivedConnection : BaseConnection { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewObjectCreation_OverridesDisposeAsync_Diagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + using System; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var conn = new DerivedConnection();|] + } + } + + class BaseConnection : IDisposable + { + public void Dispose() { } + public virtual ValueTask DisposeAsync() => default; + } + + class DerivedConnection : BaseConnection + { + public override ValueTask DisposeAsync() => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_SealedType_InheritedDisposeAsync_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + using System; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var conn = CreateConnection(); + } + + private SealedConnection CreateConnection() => new SealedConnection(); + } + + class BaseConnection : IDisposable + { + public void Dispose() { } + public virtual ValueTask DisposeAsync() => default; + } + + sealed class SealedConnection : BaseConnection { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_SealedType_OverridesDisposeAsync_Diagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" + using System; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var conn = CreateConnection();|] + } + + private SealedConnection CreateConnection() => new SealedConnection(); + } + + class BaseConnection : IDisposable + { + public void Dispose() { } + public virtual ValueTask DisposeAsync() => default; + } + + sealed class SealedConnection : BaseConnection + { + public override ValueTask DisposeAsync() => throw null; + } + """) + .ValidateAsync(); + } } From 16167b79a323bce32707ca7706bc3a5accac3d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Mon, 4 May 2026 15:46:07 -0400 Subject: [PATCH 2/4] MA0042: Check DbConnection.DisposeAsync override before warning For DbConnection subclasses, only warn about 'using' (instead of 'await using') when the exact type being instantiated has a meaningful DisposeAsync override. DbConnection.DisposeAsync itself just calls Dispose() synchronously, so inheriting it without overriding brings no benefit from 'await using'. - Restore MemoryStream special case (unchanged) - Add DbConnectionSymbol field for System.Data.Common.DbConnection - Add HasDisposeAsyncMethodDeclaredInDbConnectionSubclass: walks the type hierarchy from T up to (but not including) DbConnection and looks for a DisposeAsync declaration via GetMembers() - In CanBeAwaitUsing: for 'new T()' where T : DbConnection, use the new method; factory/method calls retain the old conservative behavior (warn, since the runtime type may be a deeper subclass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...otUseBlockingCallInAsyncContextAnalyzer.cs | 61 +++++--- ...nAsyncContextAnalyzer_AsyncContextTests.cs | 133 +++++++++--------- 2 files changed, 108 insertions(+), 86 deletions(-) diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs index 4b0f476b7..d96ace33f 100755 --- a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs @@ -75,6 +75,8 @@ public Context(Compilation compilation) } ProcessSymbol = compilation.GetBestTypeByMetadataName("System.Diagnostics.Process"); + MemoryStreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.MemoryStream"); + DbConnectionSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbConnection"); CancellationTokenSymbol = compilation.GetBestTypeByMetadataName("System.Threading.CancellationToken"); ObsoleteAttributeSymbol = compilation.GetBestTypeByMetadataName("System.ObsoleteAttribute"); @@ -121,7 +123,9 @@ public Context(Compilation compilation) _taskAwaiterLikeSymbols = [.. taskAwaiterLikeSymbols]; } + private ISymbol? MemoryStreamSymbol { get; } private ISymbol? ProcessSymbol { get; } + private INamedTypeSymbol? DbConnectionSymbol { get; } private ISymbol[] ConsoleErrorAndOutSymbols { get; } private INamedTypeSymbol? CancellationTokenSymbol { get; } private INamedTypeSymbol? ObsoleteAttributeSymbol { get; } @@ -452,24 +456,36 @@ private bool HasDisposeAsyncMethod(INamedTypeSymbol symbol) return false; } - private bool HasDisposeAsyncMethodDeclaredDirectly(INamedTypeSymbol symbol) + /// + /// Checks whether any type in the hierarchy from up to (but NOT including) + /// declares or overrides a DisposeAsync method. + /// Used to detect whether a subclass has a meaningful + /// (truly async) DisposeAsync override, as opposed to relying on + /// DbConnection.DisposeAsync which merely calls Dispose() synchronously. + /// + private bool HasDisposeAsyncMethodDeclaredInDbConnectionSubclass(INamedTypeSymbol symbol) { - // Only look at members declared directly on the type, not inherited ones. - foreach (var member in symbol.GetMembers("DisposeAsync").OfType()) + var current = symbol; + while (current is not null && !current.IsEqualTo(DbConnectionSymbol)) { - if (member.Parameters.Length != 0) - continue; + foreach (var member in current.GetMembers("DisposeAsync").OfType()) + { + if (member.Parameters.Length != 0) + continue; - if (member.IsGenericMethod) - continue; + if (member.IsGenericMethod) + continue; - if (member.IsStatic) - continue; + if (member.IsStatic) + continue; - if (!member.ReturnType.IsEqualTo(ValueTaskSymbol)) - continue; + if (!member.ReturnType.IsEqualTo(ValueTaskSymbol)) + continue; - return true; + return true; + } + + current = current.BaseType; } return false; @@ -480,13 +496,20 @@ private bool CanBeAwaitUsing(IOperation operation) if (operation.GetActualType() is not INamedTypeSymbol type) return false; - // When the runtime type is statically known — either because the expression is a direct - // object creation (new T()) or because the type is sealed — only report if the type - // itself declares/overrides DisposeAsync. Inheriting a non-async "default" DisposeAsync - // (e.g. DbConnection.DisposeAsync which just calls Dispose()) is not a meaningful override. - var unwrapped = operation.UnwrapImplicitConversionOperations(); - if (unwrapped is IObjectCreationOperation || type.IsSealed) - return HasDisposeAsyncMethodDeclaredDirectly(type); + // using var ms = new MemoryStream(); + if (operation is IObjectCreationOperation && type.IsEqualTo(MemoryStreamSymbol)) + return false; + + // For DbConnection subclasses created directly (new T()), only report if the exact + // type being instantiated (or an intermediate subclass up to but not including + // DbConnection) actually overrides DisposeAsync. DbConnection.DisposeAsync just calls + // Dispose() synchronously, so it is not a meaningful async override. + if (DbConnectionSymbol is not null && type.InheritsFrom(DbConnectionSymbol)) + { + var unwrapped = operation.UnwrapImplicitConversionOperations(); + if (unwrapped is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInDbConnectionSubclass(type); + } return HasDisposeAsyncMethod(type); } diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs index 1ee5def43..5eb9bb27b 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs @@ -2328,38 +2328,48 @@ public async Task A() } [Fact] - public async Task UsingNewObjectCreation_InheritedDisposeAsync_NoDiagnostic() + public async Task UsingNewDbConnectionSubclass_NoDisposeAsyncOverride_NoDiagnostic() { await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) .WithSourceCode(""" - using System; + using System.Data; + using System.Data.Common; using System.Threading.Tasks; class Test { public async Task A() { - using var conn = new DerivedConnection(); + using var conn = new MySqlConnection(); } } - class BaseConnection : IDisposable + class MySqlConnection : DbConnection { - public void Dispose() { } - public virtual ValueTask DisposeAsync() => default; + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw null; + protected override DbCommand CreateDbCommand() => throw null; + public override void ChangeDatabase(string databaseName) => throw null; + public override void Close() => throw null; + public override void Open() => throw null; + public override string ConnectionString { get => throw null; set => throw null; } + public override string Database => throw null; + public override string DataSource => throw null; + public override string ServerVersion => throw null; + public override ConnectionState State => throw null; } - - class DerivedConnection : BaseConnection { } """) .ValidateAsync(); } [Fact] - public async Task UsingFactoryMethod_InheritedDisposeAsync_Diagnostic() + public async Task UsingFactoryMethod_DbConnectionSubclass_NoDisposeAsyncOverride_Diagnostic() { await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) .WithSourceCode(""" - using System; + using System.Data; + using System.Data.Common; using System.Threading.Tasks; class Test @@ -2369,44 +2379,56 @@ public async Task A() [|using var conn = CreateConnection();|] } - private DerivedConnection CreateConnection() => new DerivedConnection(); + private MySqlConnection CreateConnection() => throw null; } - class BaseConnection : IDisposable + class MySqlConnection : DbConnection { - public void Dispose() { } - public virtual ValueTask DisposeAsync() => default; + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw null; + protected override DbCommand CreateDbCommand() => throw null; + public override void ChangeDatabase(string databaseName) => throw null; + public override void Close() => throw null; + public override void Open() => throw null; + public override string ConnectionString { get => throw null; set => throw null; } + public override string Database => throw null; + public override string DataSource => throw null; + public override string ServerVersion => throw null; + public override ConnectionState State => throw null; } - - class DerivedConnection : BaseConnection { } """) .ValidateAsync(); } [Fact] - public async Task UsingNewObjectCreation_OverridesDisposeAsync_Diagnostic() + public async Task UsingNewDbConnectionSubclass_WithDisposeAsyncOverride_Diagnostic() { await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) .WithSourceCode(""" - using System; + using System.Data; + using System.Data.Common; using System.Threading.Tasks; class Test { public async Task A() { - [|using var conn = new DerivedConnection();|] + [|using var conn = new MySqlConnection();|] } } - class BaseConnection : IDisposable - { - public void Dispose() { } - public virtual ValueTask DisposeAsync() => default; - } - - class DerivedConnection : BaseConnection + class MySqlConnection : DbConnection { + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw null; + protected override DbCommand CreateDbCommand() => throw null; + public override void ChangeDatabase(string databaseName) => throw null; + public override void Close() => throw null; + public override void Open() => throw null; + public override string ConnectionString { get => throw null; set => throw null; } + public override string Database => throw null; + public override string DataSource => throw null; + public override string ServerVersion => throw null; + public override ConnectionState State => throw null; public override ValueTask DisposeAsync() => throw null; } """) @@ -2414,62 +2436,39 @@ class DerivedConnection : BaseConnection } [Fact] - public async Task UsingFactoryMethod_SealedType_InheritedDisposeAsync_NoDiagnostic() - { - await CreateProjectBuilder() - .WithSourceCode(""" - using System; - using System.Threading.Tasks; - - class Test - { - public async Task A() - { - using var conn = CreateConnection(); - } - - private SealedConnection CreateConnection() => new SealedConnection(); - } - - class BaseConnection : IDisposable - { - public void Dispose() { } - public virtual ValueTask DisposeAsync() => default; - } - - sealed class SealedConnection : BaseConnection { } - """) - .ValidateAsync(); - } - - [Fact] - public async Task UsingFactoryMethod_SealedType_OverridesDisposeAsync_Diagnostic() + public async Task UsingNewDbConnectionSubclass_DisposeAsyncOverriddenInIntermediateBase_Diagnostic() { await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) .WithSourceCode(""" - using System; + using System.Data; + using System.Data.Common; using System.Threading.Tasks; class Test { public async Task A() { - [|using var conn = CreateConnection();|] + [|using var conn = new DerivedConnection();|] } - - private SealedConnection CreateConnection() => new SealedConnection(); } - class BaseConnection : IDisposable - { - public void Dispose() { } - public virtual ValueTask DisposeAsync() => default; - } - - sealed class SealedConnection : BaseConnection + class BaseConnection : DbConnection { + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw null; + protected override DbCommand CreateDbCommand() => throw null; + public override void ChangeDatabase(string databaseName) => throw null; + public override void Close() => throw null; + public override void Open() => throw null; + public override string ConnectionString { get => throw null; set => throw null; } + public override string Database => throw null; + public override string DataSource => throw null; + public override string ServerVersion => throw null; + public override ConnectionState State => throw null; public override ValueTask DisposeAsync() => throw null; } + + class DerivedConnection : BaseConnection { } """) .ValidateAsync(); } From a55906bcbeb82768f5aa0ecd8f7e43cfe457080f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Mon, 4 May 2026 19:13:22 -0400 Subject: [PATCH 3/4] docs: document DbConnection DisposeAsync suppression logic in MA0042 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Rules/MA0042.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/Rules/MA0042.md b/docs/Rules/MA0042.md index 6147b9b15..022150a08 100644 --- a/docs/Rules/MA0042.md +++ b/docs/Rules/MA0042.md @@ -55,6 +55,20 @@ public sealed class Sample The rule does not report a diagnostic for `IDbContextFactory.CreateDbContext()`. The `CreateDbContextAsync()` overload was introduced only for specific edge-case scenarios where the factory itself must perform asynchronous initialization, and is not intended as a general-purpose replacement. See [dotnet/efcore#26630](https://github.com/dotnet/efcore/issues/26630) for more details. +The rule will not report a diagnostic for a `using` statement on a `DbConnection` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. `DbConnection.DisposeAsync` merely calls `Dispose()` synchronously, so switching to `await using` brings no benefit for such types. When the instance is obtained from a factory method rather than a direct `new` expression, the rule still reports a diagnostic because the runtime type may be a deeper subclass that does override `DisposeAsync`. + +````csharp +public async Task Sample() +{ + // No diagnostic: SqlConnection does not override DisposeAsync, + // so using vs await using makes no difference. + using var connection1 = new SqlConnection(connectionString); + + // Diagnostic: obtained from a factory, runtime type may override DisposeAsync. + using var connection2 = CreateConnection(); +} +```` + ## Additional resources - [Enforcing asynchronous code good practices using a Roslyn analyzer](https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm) From 98359678a941a8b9de014bec435c3f04399ed006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Mon, 4 May 2026 19:22:16 -0400 Subject: [PATCH 4/4] MA0042: Replace hardcoded MemoryStream check with Stream hierarchy logic Instead of a special-cased MemoryStream exemption, apply the same DisposeAsync-override walk used for DbConnection to all Stream subclasses: when a Stream subclass is directly instantiated with 'new' and does not override DisposeAsync in the subclass chain (up to but not including Stream), do not report a diagnostic. - Replace MemoryStreamSymbol with StreamSymbol (System.IO.Stream) - Replace HasDisposeAsyncMethodDeclaredInDbConnectionSubclass with the generic HasDisposeAsyncMethodDeclaredInSubclass(symbol, baseTypeSymbol) - CanBeAwaitUsing now applies the check for both Stream and DbConnection - Add two new Stream-specific tests - Update MA0042.md to document Stream alongside DbConnection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Rules/MA0042.md | 7 +- ...otUseBlockingCallInAsyncContextAnalyzer.cs | 30 ++++---- ...nAsyncContextAnalyzer_AsyncContextTests.cs | 72 +++++++++++++++++++ 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/docs/Rules/MA0042.md b/docs/Rules/MA0042.md index 022150a08..e3bfbf550 100644 --- a/docs/Rules/MA0042.md +++ b/docs/Rules/MA0042.md @@ -55,13 +55,16 @@ public sealed class Sample The rule does not report a diagnostic for `IDbContextFactory.CreateDbContext()`. The `CreateDbContextAsync()` overload was introduced only for specific edge-case scenarios where the factory itself must perform asynchronous initialization, and is not intended as a general-purpose replacement. See [dotnet/efcore#26630](https://github.com/dotnet/efcore/issues/26630) for more details. -The rule will not report a diagnostic for a `using` statement on a `DbConnection` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. `DbConnection.DisposeAsync` merely calls `Dispose()` synchronously, so switching to `await using` brings no benefit for such types. When the instance is obtained from a factory method rather than a direct `new` expression, the rule still reports a diagnostic because the runtime type may be a deeper subclass that does override `DisposeAsync`. +The rule will not report a diagnostic for a `using` statement on a `Stream` or `DbConnection` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. Both `Stream.DisposeAsync` and `DbConnection.DisposeAsync` merely call `Dispose()` synchronously by default, so switching to `await using` brings no benefit for such types. When the instance is obtained from a factory method rather than a direct `new` expression, the rule still reports a diagnostic because the runtime type may be a deeper subclass that does override `DisposeAsync`. ````csharp public async Task Sample() { - // No diagnostic: SqlConnection does not override DisposeAsync, + // No diagnostic: MemoryStream does not override DisposeAsync, // so using vs await using makes no difference. + using var ms = new MemoryStream(); + + // No diagnostic: SqlConnection does not override DisposeAsync. using var connection1 = new SqlConnection(connectionString); // Diagnostic: obtained from a factory, runtime type may override DisposeAsync. diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs index d96ace33f..f33fbcfc8 100755 --- a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs @@ -75,7 +75,7 @@ public Context(Compilation compilation) } ProcessSymbol = compilation.GetBestTypeByMetadataName("System.Diagnostics.Process"); - MemoryStreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.MemoryStream"); + StreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.Stream"); DbConnectionSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbConnection"); CancellationTokenSymbol = compilation.GetBestTypeByMetadataName("System.Threading.CancellationToken"); ObsoleteAttributeSymbol = compilation.GetBestTypeByMetadataName("System.ObsoleteAttribute"); @@ -123,7 +123,7 @@ public Context(Compilation compilation) _taskAwaiterLikeSymbols = [.. taskAwaiterLikeSymbols]; } - private ISymbol? MemoryStreamSymbol { get; } + private ISymbol? StreamSymbol { get; } private ISymbol? ProcessSymbol { get; } private INamedTypeSymbol? DbConnectionSymbol { get; } private ISymbol[] ConsoleErrorAndOutSymbols { get; } @@ -458,15 +458,14 @@ private bool HasDisposeAsyncMethod(INamedTypeSymbol symbol) /// /// Checks whether any type in the hierarchy from up to (but NOT including) - /// declares or overrides a DisposeAsync method. - /// Used to detect whether a subclass has a meaningful - /// (truly async) DisposeAsync override, as opposed to relying on - /// DbConnection.DisposeAsync which merely calls Dispose() synchronously. + /// declares or overrides a DisposeAsync method. + /// Used to detect whether a subclass has a meaningful (truly async) DisposeAsync override, + /// as opposed to relying on an inherited implementation that is not truly asynchronous. /// - private bool HasDisposeAsyncMethodDeclaredInDbConnectionSubclass(INamedTypeSymbol symbol) + private bool HasDisposeAsyncMethodDeclaredInSubclass(INamedTypeSymbol symbol, INamedTypeSymbol baseTypeSymbol) { var current = symbol; - while (current is not null && !current.IsEqualTo(DbConnectionSymbol)) + while (current is not null && !current.IsEqualTo(baseTypeSymbol)) { foreach (var member in current.GetMembers("DisposeAsync").OfType()) { @@ -496,9 +495,16 @@ private bool CanBeAwaitUsing(IOperation operation) if (operation.GetActualType() is not INamedTypeSymbol type) return false; - // using var ms = new MemoryStream(); - if (operation is IObjectCreationOperation && type.IsEqualTo(MemoryStreamSymbol)) - return false; + // For Stream subclasses (including MemoryStream) created directly (new T()), only report + // if the concrete type being instantiated (or an intermediate subclass up to but not + // including Stream) actually overrides DisposeAsync. Stream.DisposeAsync merely calls + // Dispose() synchronously by default, so it is not a meaningful async override. + if (StreamSymbol is INamedTypeSymbol streamSymbol && type.InheritsFrom(streamSymbol)) + { + var unwrapped = operation.UnwrapImplicitConversionOperations(); + if (unwrapped is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInSubclass(type, streamSymbol); + } // For DbConnection subclasses created directly (new T()), only report if the exact // type being instantiated (or an intermediate subclass up to but not including @@ -508,7 +514,7 @@ private bool CanBeAwaitUsing(IOperation operation) { var unwrapped = operation.UnwrapImplicitConversionOperations(); if (unwrapped is IObjectCreationOperation) - return HasDisposeAsyncMethodDeclaredInDbConnectionSubclass(type); + return HasDisposeAsyncMethodDeclaredInSubclass(type, DbConnectionSymbol); } return HasDisposeAsyncMethod(type); diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs index 5eb9bb27b..66fce0828 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs @@ -2112,6 +2112,78 @@ await CreateProjectBuilder() .ValidateAsync(); } + [Fact] + public async Task UsingFactoryMethod_StreamSubclass_NoDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.IO; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var s = CreateStream();|] + } + + private MyStream CreateStream() => throw null; + } + + class MyStream : Stream + { + public override bool CanRead => throw null; + public override bool CanSeek => throw null; + public override bool CanWrite => throw null; + public override long Length => throw null; + public override long Position { get => throw null; set => throw null; } + public override void Flush() => throw null; + public override int Read(byte[] buffer, int offset, int count) => throw null; + public override long Seek(long offset, SeekOrigin origin) => throw null; + public override void SetLength(long value) => throw null; + public override void Write(byte[] buffer, int offset, int count) => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewStreamSubclass_WithDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.IO; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var s = new MyStream();|] + } + } + + class MyStream : Stream + { + public override bool CanRead => throw null; + public override bool CanSeek => throw null; + public override bool CanWrite => throw null; + public override long Length => throw null; + public override long Position { get => throw null; set => throw null; } + public override void Flush() => throw null; + public override int Read(byte[] buffer, int offset, int count) => throw null; + public override long Seek(long offset, SeekOrigin origin) => throw null; + public override void SetLength(long value) => throw null; + public override void Write(byte[] buffer, int offset, int count) => throw null; + public override ValueTask DisposeAsync() => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] public async Task SemaphoreSlim_Wait_NoDiagnostic() {