diff --git a/docs/Rules/MA0042.md b/docs/Rules/MA0042.md index dbb7b91d4..4ffa3ec8f 100644 --- a/docs/Rules/MA0042.md +++ b/docs/Rules/MA0042.md @@ -62,7 +62,7 @@ The rule does not report a diagnostic for method invocations on the following SQ SQLite async APIs have documented limitations. See [Async limitations](https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/async) for details. -The rule will not report a diagnostic for a `using` statement on a `Stream`, `DbConnection`, or `DbCommand` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. `Stream.DisposeAsync`, `DbConnection.DisposeAsync`, and `DbCommand.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`. +The rule will not report a diagnostic for a `using` statement on a `Stream`, `TextWriter`, `DbConnection`, `DbCommand`, `DbDataReader`, `DbTransaction`, or `DbBatch` subclass that is directly instantiated with `new` when the concrete type does not override `DisposeAsync`. The base implementations of `DisposeAsync` for these types 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() @@ -82,6 +82,18 @@ public async Task Sample() // Diagnostic: obtained from a factory, runtime type may override DisposeAsync. using var command2 = CreateCommand(); + + // No diagnostic: DataTableReader does not override DisposeAsync. + using var reader1 = new DataTableReader(new DataTable()); + + // Diagnostic: obtained from a factory, runtime type may override DisposeAsync. + using var reader2 = CreateReader(); + + // No diagnostic: StringWriter does not override DisposeAsync. + using var writer1 = new StringWriter(); + + // Diagnostic: obtained from a factory, runtime type may override DisposeAsync. + using var writer2 = CreateWriter(); } ```` diff --git a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs index c849b397b..e6a5933b9 100755 --- a/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer.cs @@ -77,8 +77,12 @@ public Context(Compilation compilation) ProcessSymbol = compilation.GetBestTypeByMetadataName("System.Diagnostics.Process"); StreamSymbol = compilation.GetBestTypeByMetadataName("System.IO.Stream"); + TextWriterSymbol = compilation.GetBestTypeByMetadataName("System.IO.TextWriter"); DbConnectionSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbConnection"); DbCommandSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbCommand"); + DbDataReaderSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbDataReader"); + DbTransactionSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbTransaction"); + DbBatchSymbol = compilation.GetBestTypeByMetadataName("System.Data.Common.DbBatch"); SqliteConnectionSymbol = compilation.GetBestTypeByMetadataName("Microsoft.Data.Sqlite.SqliteConnection"); SqliteCommandSymbol = compilation.GetBestTypeByMetadataName("Microsoft.Data.Sqlite.SqliteCommand"); SqliteDataReaderSymbol = compilation.GetBestTypeByMetadataName("Microsoft.Data.Sqlite.SqliteDataReader"); @@ -129,9 +133,13 @@ public Context(Compilation compilation) } private ISymbol? StreamSymbol { get; } + private INamedTypeSymbol? TextWriterSymbol { get; } private ISymbol? ProcessSymbol { get; } private INamedTypeSymbol? DbConnectionSymbol { get; } private INamedTypeSymbol? DbCommandSymbol { get; } + private INamedTypeSymbol? DbDataReaderSymbol { get; } + private INamedTypeSymbol? DbTransactionSymbol { get; } + private INamedTypeSymbol? DbBatchSymbol { get; } private INamedTypeSymbol? SqliteConnectionSymbol { get; } private INamedTypeSymbol? SqliteCommandSymbol { get; } private INamedTypeSymbol? SqliteDataReaderSymbol { get; } @@ -580,6 +588,46 @@ unwrappedOperation is IInvocationOperation invocationOperation && return HasDisposeAsyncMethodDeclaredInSubclass(type, DbCommandSymbol); } + // For DbDataReader subclasses created directly (new T()), only report if the exact + // type being instantiated (or an intermediate subclass up to but not including + // DbDataReader) actually overrides DisposeAsync. DbDataReader.DisposeAsync just calls + // Dispose() synchronously, so it is not a meaningful async override. + if (DbDataReaderSymbol is not null && type.InheritsFrom(DbDataReaderSymbol)) + { + if (unwrappedOperation is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInSubclass(type, DbDataReaderSymbol); + } + + // For DbTransaction subclasses created directly (new T()), only report if the exact + // type being instantiated (or an intermediate subclass up to but not including + // DbTransaction) actually overrides DisposeAsync. DbTransaction.DisposeAsync just calls + // Dispose() synchronously, so it is not a meaningful async override. + if (DbTransactionSymbol is not null && type.InheritsFrom(DbTransactionSymbol)) + { + if (unwrappedOperation is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInSubclass(type, DbTransactionSymbol); + } + + // For DbBatch subclasses created directly (new T()), only report if the exact + // type being instantiated (or an intermediate subclass up to but not including + // DbBatch) actually overrides DisposeAsync. DbBatch.DisposeAsync just calls + // Dispose() synchronously, so it is not a meaningful async override. + if (DbBatchSymbol is not null && type.InheritsFrom(DbBatchSymbol)) + { + if (unwrappedOperation is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInSubclass(type, DbBatchSymbol); + } + + // For TextWriter subclasses created directly (new T()), only report if the exact + // type being instantiated (or an intermediate subclass up to but not including + // TextWriter) actually overrides DisposeAsync. TextWriter.DisposeAsync just calls + // Dispose() synchronously by default, so it is not a meaningful async override. + if (TextWriterSymbol is not null && type.InheritsFrom(TextWriterSymbol)) + { + if (unwrappedOperation is IObjectCreationOperation) + return HasDisposeAsyncMethodDeclaredInSubclass(type, TextWriterSymbol); + } + return HasDisposeAsyncMethod(type); } diff --git a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs index 6d20ec451..7d9b35f72 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/DoNotUseBlockingCallInAsyncContextAnalyzer_AsyncContextTests.cs @@ -2935,4 +2935,410 @@ class DerivedDbCommand : BaseDbCommand { } """) .ValidateAsync(); } + + [Fact] + public async Task UsingNewDbDataReaderSubclass_NoDisposeAsyncOverride_NoDiagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var reader = new DataTableReader(new DataTable()); + } + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_DbDataReaderSubclass_NoDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var reader = CreateReader();|] + } + + private DataTableReader CreateReader() => new DataTableReader(new DataTable()); + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewDbDataReaderSubclass_WithDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System; + using System.Collections; + using System.Data.Common; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var reader1 = new MyDbDataReader();|] + [|using var reader2 = new DerivedDbDataReader();|] + } + } + + class MyDbDataReader : DbDataReader + { + public override object this[int ordinal] => throw null; + public override object this[string name] => throw null; + public override int Depth => throw null; + public override int FieldCount => throw null; + public override bool HasRows => throw null; + public override bool IsClosed => throw null; + public override int RecordsAffected => throw null; + public override bool GetBoolean(int ordinal) => throw null; + public override byte GetByte(int ordinal) => throw null; + public override long GetBytes(int ordinal, long dataOffset, byte[] buffer, int bufferOffset, int length) => throw null; + public override char GetChar(int ordinal) => throw null; + public override long GetChars(int ordinal, long dataOffset, char[] buffer, int bufferOffset, int length) => throw null; + public override string GetDataTypeName(int ordinal) => throw null; + public override DateTime GetDateTime(int ordinal) => throw null; + public override decimal GetDecimal(int ordinal) => throw null; + public override double GetDouble(int ordinal) => throw null; + public override IEnumerator GetEnumerator() => throw null; + public override Type GetFieldType(int ordinal) => throw null; + public override float GetFloat(int ordinal) => throw null; + public override Guid GetGuid(int ordinal) => throw null; + public override short GetInt16(int ordinal) => throw null; + public override int GetInt32(int ordinal) => throw null; + public override long GetInt64(int ordinal) => throw null; + public override string GetName(int ordinal) => throw null; + public override int GetOrdinal(string name) => throw null; + public override string GetString(int ordinal) => throw null; + public override object GetValue(int ordinal) => throw null; + public override int GetValues(object[] values) => throw null; + public override bool IsDBNull(int ordinal) => throw null; + public override bool NextResult() => throw null; + public override bool Read() => throw null; + public override ValueTask DisposeAsync() => throw null; + } + + class DerivedDbDataReader : MyDbDataReader { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewDbTransactionSubclass_NoDisposeAsyncOverride_NoDiagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var transaction = new MyDbTransaction(); + } + } + + class MyDbTransaction : DbTransaction + { + protected override DbConnection DbConnection => throw null; + public override IsolationLevel IsolationLevel => throw null; + public override void Commit() => throw null; + public override void Rollback() => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_DbTransactionSubclass_NoDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var transaction = CreateTransaction();|] + } + + private MyDbTransaction CreateTransaction() => throw null; + } + + class MyDbTransaction : DbTransaction + { + protected override DbConnection DbConnection => throw null; + public override IsolationLevel IsolationLevel => throw null; + public override void Commit() => throw null; + public override void Rollback() => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewDbTransactionSubclass_WithDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var transaction1 = new MyDbTransaction();|] + [|using var transaction2 = new DerivedDbTransaction();|] + } + } + + class MyDbTransaction : DbTransaction + { + protected override DbConnection DbConnection => throw null; + public override IsolationLevel IsolationLevel => throw null; + public override void Commit() => throw null; + public override void Rollback() => throw null; + public override ValueTask DisposeAsync() => throw null; + } + + class DerivedDbTransaction : MyDbTransaction { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewDbBatchSubclass_NoDisposeAsyncOverride_NoDiagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var batch = new MyDbBatch(); + } + } + + class MyDbBatch : DbBatch + { + public override int Timeout { get => throw null; set => throw null; } + protected override DbBatchCommandCollection DbBatchCommands => throw null; + protected override DbConnection DbConnection { get => throw null; set => throw null; } + protected override DbTransaction DbTransaction { get => throw null; set => throw null; } + public override void Cancel() => throw null; + protected override DbBatchCommand CreateDbBatchCommand() => throw null; + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw null; + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => throw null; + public override int ExecuteNonQuery() => throw null; + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken = default) => throw null; + public override object ExecuteScalar() => throw null; + public override Task ExecuteScalarAsync(CancellationToken cancellationToken = default) => throw null; + public override void Prepare() => throw null; + public override Task PrepareAsync(CancellationToken cancellationToken = default) => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_DbBatchSubclass_NoDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var batch = CreateBatch();|] + } + + private MyDbBatch CreateBatch() => throw null; + } + + class MyDbBatch : DbBatch + { + public override int Timeout { get => throw null; set => throw null; } + protected override DbBatchCommandCollection DbBatchCommands => throw null; + protected override DbConnection DbConnection { get => throw null; set => throw null; } + protected override DbTransaction DbTransaction { get => throw null; set => throw null; } + public override void Cancel() => throw null; + protected override DbBatchCommand CreateDbBatchCommand() => throw null; + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw null; + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => throw null; + public override int ExecuteNonQuery() => throw null; + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken = default) => throw null; + public override object ExecuteScalar() => throw null; + public override Task ExecuteScalarAsync(CancellationToken cancellationToken = default) => throw null; + public override void Prepare() => throw null; + public override Task PrepareAsync(CancellationToken cancellationToken = default) => throw null; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewDbBatchSubclass_WithDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.Data; + using System.Data.Common; + using System.Threading; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var batch1 = new MyDbBatch();|] + [|using var batch2 = new DerivedDbBatch();|] + } + } + + class MyDbBatch : DbBatch + { + public override int Timeout { get => throw null; set => throw null; } + protected override DbBatchCommandCollection DbBatchCommands => throw null; + protected override DbConnection DbConnection { get => throw null; set => throw null; } + protected override DbTransaction DbTransaction { get => throw null; set => throw null; } + public override void Cancel() => throw null; + protected override DbBatchCommand CreateDbBatchCommand() => throw null; + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw null; + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => throw null; + public override int ExecuteNonQuery() => throw null; + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken = default) => throw null; + public override object ExecuteScalar() => throw null; + public override Task ExecuteScalarAsync(CancellationToken cancellationToken = default) => throw null; + public override void Prepare() => throw null; + public override Task PrepareAsync(CancellationToken cancellationToken = default) => throw null; + public override ValueTask DisposeAsync() => throw null; + } + + class DerivedDbBatch : MyDbBatch { } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewTextWriterSubclass_NoDisposeAsyncOverride_NoDiagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.IO; + using System.Text; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + using var writer = new MyTextWriter(); + } + } + + class MyTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingFactoryMethod_TextWriterSubclass_NoDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.IO; + using System.Text; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var writer = CreateTextWriter();|] + } + + private MyTextWriter CreateTextWriter() => new MyTextWriter(); + } + + class MyTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + """) + .ValidateAsync(); + } + + [Fact] + public async Task UsingNewTextWriterSubclass_WithDisposeAsyncOverride_Diagnostic() + { + await CreateProjectBuilder() + .WithTargetFramework(TargetFramework.Net8_0) + .WithSourceCode(""" + using System.IO; + using System.Text; + using System.Threading.Tasks; + + class Test + { + public async Task A() + { + [|using var writer1 = new MyTextWriter();|] + [|using var writer2 = new DerivedTextWriter();|] + } + } + + class MyTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + public override ValueTask DisposeAsync() => throw null; + } + + class DerivedTextWriter : MyTextWriter { } + """) + .ValidateAsync(); + } }