From 05f6a301c6e2902df6f95f2c444da1e3d4e4dc6d Mon Sep 17 00:00:00 2001 From: Raul Almeida Date: Thu, 18 Dec 2025 14:47:28 -0300 Subject: [PATCH] Fix memory leak in TypeHandlerBase.DictionaryKeyComparer.Equals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug was in line 22 of TypeHandlerBase.cs: if (x != null && y != null) return false; This condition returns false when BOTH objects are non-null, which is the normal comparison case. This caused the ConcurrentDictionary to: 1. Never find existing cache entries (Equals always returns false) 2. Add a new entry for every SetValue() call 3. Grow unboundedly causing memory leaks in production The fix changes the null checks to proper equality semantics: if (x == null && y == null) return true; // Both null = equal if (x == null || y == null) return false; // One null = not equal Added unit test to verify cache reuse behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../TypeHandler/TypeHandlerBase.cs | 4 +- .../GuidRaw16TypeHandlerTests.cs | 54 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/Dapper.Oracle/TypeHandler/TypeHandlerBase.cs b/src/Dapper.Oracle/TypeHandler/TypeHandlerBase.cs index e2b2fbc..abf0b1b 100644 --- a/src/Dapper.Oracle/TypeHandler/TypeHandlerBase.cs +++ b/src/Dapper.Oracle/TypeHandler/TypeHandlerBase.cs @@ -18,8 +18,8 @@ private class DictionaryKeyComparer : IEqualityComparer { public bool Equals(DictionaryKey x, DictionaryKey y) { - if (x == null && y != null) return false; - if (x != null && y != null) return false; + if (x == null && y == null) return true; + if (x == null || y == null) return false; return x.ParameterType == y.ParameterType && x.OracleTypeName.Equals(y.OracleTypeName); } diff --git a/src/Tests.Dapper.Oracle/TypeHandlerTests/GuidRaw16TypeHandlerTests.cs b/src/Tests.Dapper.Oracle/TypeHandlerTests/GuidRaw16TypeHandlerTests.cs index b7da873..b39411c 100644 --- a/src/Tests.Dapper.Oracle/TypeHandlerTests/GuidRaw16TypeHandlerTests.cs +++ b/src/Tests.Dapper.Oracle/TypeHandlerTests/GuidRaw16TypeHandlerTests.cs @@ -1,4 +1,7 @@ using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; using Dapper.Oracle.TypeHandler; using Dapper.Oracle.TypeMapping; using FluentAssertions; @@ -17,21 +20,62 @@ public void ConvertTo() var parameter = new OracleParameter(); var sut = new GuidRaw16TypeHandler(); sut.SetValue(parameter,input); - + parameter.Value.Should().BeEquivalentTo(input.ToByteArray()); parameter.OracleDbType.Should().Be(OracleDbType.Raw); } - + [Fact] public void ConvertFrom() { Guid input = Guid.NewGuid(); - + var sut = new GuidRaw16TypeHandler(); var result = sut.Parse(input.ToByteArray()); result.Should().Be(input); } - - + + /// + /// Verifies that TypeHandlerBase.OracleDbTypeProperty cache does not grow + /// unboundedly when SetValue is called multiple times with the same parameter type. + /// + /// This test catches a bug in DictionaryKeyComparer.Equals where the condition + /// "if (x != null && y != null) return false" caused every cache lookup to fail, + /// resulting in unbounded cache growth and memory leaks. + /// + [Fact] + public void SetValue_ShouldReuseCache_WhenCalledMultipleTimes() + { + var sut = new GuidRaw16TypeHandler(); + + // Get the OracleDbTypeProperty cache via reflection + var baseType = typeof(GuidRaw16TypeHandler).BaseType; + var cacheField = baseType?.GetField("OracleDbTypeProperty", + BindingFlags.NonPublic | BindingFlags.Static); + + cacheField.Should().NotBeNull("TypeHandlerBase should have OracleDbTypeProperty field"); + + var cache = cacheField.GetValue(null) as ICollection; + cache.Should().NotBeNull("OracleDbTypeProperty should be a collection"); + + // Record initial cache count + int initialCount = cache.Count; + + // Call SetValue multiple times with the same parameter type + const int iterations = 100; + for (int i = 0; i < iterations; i++) + { + var parameter = new OracleParameter(); + sut.SetValue(parameter, Guid.NewGuid()); + } + + // Cache should have grown by at most 1 entry (for the OracleParameter + "Raw" combination) + int finalCount = cache.Count; + int cacheGrowth = finalCount - initialCount; + + cacheGrowth.Should().BeLessOrEqualTo(1, + $"cache should reuse entries for same parameter type, but grew by {cacheGrowth} entries after {iterations} calls. " + + "This indicates a bug in DictionaryKeyComparer.Equals causing cache entries to never match."); + } } } \ No newline at end of file