From d07b41b362aff5e9c36b98429242772dbf35d8cd Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Sat, 24 Oct 2026 08:09:26 -0500 Subject: [PATCH 1/2] Dispose wrapped commands asynchronously --- Dapper/WrappedReader.cs | 14 ++++++++++- Directory.Packages.props | 1 + tests/Dapper.Tests/Dapper.Tests.csproj | 1 + tests/Dapper.Tests/FakeDbTests.Issue2185.cs | 27 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/Dapper.Tests/FakeDbTests.Issue2185.cs diff --git a/Dapper/WrappedReader.cs b/Dapper/WrappedReader.cs index 04321f976..a98ef7136 100644 --- a/Dapper/WrappedReader.cs +++ b/Dapper/WrappedReader.cs @@ -203,7 +203,19 @@ public override long GetChars(int i, long fieldoffset, char[]? buffer, int buffe #if NET5_0_OR_GREATER public override Task CloseAsync() => _reader.CloseAsync(); - public override ValueTask DisposeAsync() => _reader.DisposeAsync(); + public override async ValueTask DisposeAsync() + { + try + { + await _reader.DisposeAsync().ConfigureAwait(false); + } + finally + { + _reader = DisposedReader.Instance; + _cmd?.Dispose(); + _cmd = null!; + } + } public override Task> GetColumnSchemaAsync(CancellationToken cancellationToken = default) => _reader.GetColumnSchemaAsync(cancellationToken); diff --git a/Directory.Packages.props b/Directory.Packages.props index 2f91a3cca..b1e42ba41 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,7 @@ + diff --git a/tests/Dapper.Tests/Dapper.Tests.csproj b/tests/Dapper.Tests/Dapper.Tests.csproj index 93b498618..e9eab6b88 100644 --- a/tests/Dapper.Tests/Dapper.Tests.csproj +++ b/tests/Dapper.Tests/Dapper.Tests.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/Dapper.Tests/FakeDbTests.Issue2185.cs b/tests/Dapper.Tests/FakeDbTests.Issue2185.cs new file mode 100644 index 000000000..352389d33 --- /dev/null +++ b/tests/Dapper.Tests/FakeDbTests.Issue2185.cs @@ -0,0 +1,27 @@ +#if !NET481 +using System.Collections.Generic; +using System.Threading.Tasks; +using pengdows.crud.fakeDb; +using Xunit; + +namespace Dapper.Tests; + +public sealed class FakeDbIssue2185Tests +{ + [Fact] + public async Task ExecuteReaderAsyncDisposeAsyncDisposesCommand() + { + using var connection = new fakeDbConnection(new FakeDataStore()); + connection.EnqueueReaderResult(new[] { new Dictionary { ["Value"] = 1 } }); + connection.Open(); + + await using (var reader = await connection.ExecuteReaderAsync("select 1")) + { + Assert.True(await reader.ReadAsync()); + } + + Assert.NotNull(connection.LastCreatedCommand); + Assert.True(connection.LastCreatedCommand!.WasDisposed); + } +} +#endif From ca8ffb21972018137c1c4d0d7c6b54bf831060e3 Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Sat, 24 Oct 2026 10:33:26 -0500 Subject: [PATCH 2/2] Dispose wrapped command via DisposeAsync when it is a DbCommand Mirror GridReader.DisposeAsync: await DbCommand.DisposeAsync, fall back to Dispose for other IDbCommand implementations, and dispose the command even when reader disposal throws. Co-Authored-By: Claude Sonnet 5.5 --- Dapper/WrappedReader.cs | 35 ++++--- tests/Dapper.Tests/FakeDbTests.Issue2185.cs | 102 ++++++++++++++++++++ 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/Dapper/WrappedReader.cs b/Dapper/WrappedReader.cs index a98ef7136..0bab16ca1 100644 --- a/Dapper/WrappedReader.cs +++ b/Dapper/WrappedReader.cs @@ -203,19 +203,28 @@ public override long GetChars(int i, long fieldoffset, char[]? buffer, int buffe #if NET5_0_OR_GREATER public override Task CloseAsync() => _reader.CloseAsync(); - public override async ValueTask DisposeAsync() - { - try - { - await _reader.DisposeAsync().ConfigureAwait(false); - } - finally - { - _reader = DisposedReader.Instance; - _cmd?.Dispose(); - _cmd = null!; - } - } + public override async ValueTask DisposeAsync() + { + var cmd = _cmd; + try + { + await _reader.DisposeAsync().ConfigureAwait(false); + } + finally + { + _reader = DisposedReader.Instance; + _cmd = null!; + // the command may support async disposal even though it is held as IDbCommand + if (cmd is DbCommand typed) + { + await typed.DisposeAsync().ConfigureAwait(false); + } + else + { + cmd?.Dispose(); + } + } + } public override Task> GetColumnSchemaAsync(CancellationToken cancellationToken = default) => _reader.GetColumnSchemaAsync(cancellationToken); diff --git a/tests/Dapper.Tests/FakeDbTests.Issue2185.cs b/tests/Dapper.Tests/FakeDbTests.Issue2185.cs index 352389d33..802d7c930 100644 --- a/tests/Dapper.Tests/FakeDbTests.Issue2185.cs +++ b/tests/Dapper.Tests/FakeDbTests.Issue2185.cs @@ -1,5 +1,9 @@ #if !NET481 +using System; using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Threading; using System.Threading.Tasks; using pengdows.crud.fakeDb; using Xunit; @@ -23,5 +27,103 @@ public async Task ExecuteReaderAsyncDisposeAsyncDisposesCommand() Assert.NotNull(connection.LastCreatedCommand); Assert.True(connection.LastCreatedCommand!.WasDisposed); } + + [Fact] + public async Task DisposeAsyncUsesAsyncDisposalOfDbCommand() + { + using var connection = new fakeDbConnection(new FakeDataStore()); + connection.EnqueueReaderResult(new[] { new Dictionary { ["Value"] = 1 } }); + connection.Open(); + var cmd = new RecordingCommand(connection.CreateCommand()); + var inner = await cmd.InnerCommand.ExecuteReaderAsync(); + + await using (new DbWrappedReader(cmd, inner)) { } + + Assert.Equal(new[] { "DisposeAsync" }, cmd.Calls); + } + + [Fact] + public async Task DisposeAsyncStillDisposesCommandWhenReaderDisposalThrows() + { + var cmd = new RecordingCommand(new fakeDbConnection(new FakeDataStore()).CreateCommand()); + var wrapped = new DbWrappedReader(cmd, new ThrowingReader()); + + await Assert.ThrowsAsync(async () => await wrapped.DisposeAsync()); + + Assert.Equal(new[] { "DisposeAsync" }, cmd.Calls); + } + + private sealed class ThrowingReader : DbDataReader + { + public override System.Threading.Tasks.ValueTask DisposeAsync() => throw new InvalidOperationException(); + public override int Depth => 0; + public override int FieldCount => 0; + public override bool HasRows => false; + public override bool IsClosed => false; + public override int RecordsAffected => 0; + public override object this[int ordinal] => throw new NotSupportedException(); + public override object this[string name] => throw new NotSupportedException(); + public override bool GetBoolean(int ordinal) => throw new NotSupportedException(); + public override byte GetByte(int ordinal) => throw new NotSupportedException(); + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override char GetChar(int ordinal) => throw new NotSupportedException(); + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override string GetDataTypeName(int ordinal) => throw new NotSupportedException(); + public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException(); + public override decimal GetDecimal(int ordinal) => throw new NotSupportedException(); + public override double GetDouble(int ordinal) => throw new NotSupportedException(); + public override System.Collections.IEnumerator GetEnumerator() => throw new NotSupportedException(); + public override Type GetFieldType(int ordinal) => throw new NotSupportedException(); + public override float GetFloat(int ordinal) => throw new NotSupportedException(); + public override Guid GetGuid(int ordinal) => throw new NotSupportedException(); + public override short GetInt16(int ordinal) => throw new NotSupportedException(); + public override int GetInt32(int ordinal) => throw new NotSupportedException(); + public override long GetInt64(int ordinal) => throw new NotSupportedException(); + public override string GetName(int ordinal) => throw new NotSupportedException(); + public override int GetOrdinal(string name) => throw new NotSupportedException(); + public override string GetString(int ordinal) => throw new NotSupportedException(); + public override object GetValue(int ordinal) => throw new NotSupportedException(); + public override int GetValues(object[] values) => throw new NotSupportedException(); + public override bool IsDBNull(int ordinal) => throw new NotSupportedException(); + public override bool NextResult() => false; + public override bool Read() => false; + } + + // Delegating command that records which disposal path was taken. + private sealed class RecordingCommand : DbCommand + { + public RecordingCommand(DbCommand inner) => InnerCommand = inner; + public DbCommand InnerCommand { get; } + public List Calls { get; } = new(); + + public override System.Threading.Tasks.ValueTask DisposeAsync() + { + Calls.Add("DisposeAsync"); + return InnerCommand.DisposeAsync(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) Calls.Add("Dispose"); + InnerCommand.Dispose(); + base.Dispose(disposing); + } + + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string CommandText { get => InnerCommand.CommandText; set => InnerCommand.CommandText = value; } + public override int CommandTimeout { get => InnerCommand.CommandTimeout; set => InnerCommand.CommandTimeout = value; } + public override CommandType CommandType { get => InnerCommand.CommandType; set => InnerCommand.CommandType = value; } + public override bool DesignTimeVisible { get => InnerCommand.DesignTimeVisible; set => InnerCommand.DesignTimeVisible = value; } + public override UpdateRowSource UpdatedRowSource { get => InnerCommand.UpdatedRowSource; set => InnerCommand.UpdatedRowSource = value; } + protected override DbConnection? DbConnection { get => InnerCommand.Connection; set => InnerCommand.Connection = value; } + protected override DbParameterCollection DbParameterCollection => InnerCommand.Parameters; + protected override DbTransaction? DbTransaction { get => InnerCommand.Transaction; set => InnerCommand.Transaction = value; } + public override void Cancel() => InnerCommand.Cancel(); + public override int ExecuteNonQuery() => InnerCommand.ExecuteNonQuery(); + public override object? ExecuteScalar() => InnerCommand.ExecuteScalar(); + public override void Prepare() => InnerCommand.Prepare(); + protected override DbParameter CreateDbParameter() => InnerCommand.CreateParameter(); + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => InnerCommand.ExecuteReader(behavior); + } } #endif