diff --git a/TUnit.Core/Helpers/ValueListBuilder.cs b/TUnit.Core/Helpers/ValueListBuilder.cs new file mode 100644 index 0000000000..ad714b060c --- /dev/null +++ b/TUnit.Core/Helpers/ValueListBuilder.cs @@ -0,0 +1,237 @@ +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace TUnit.Core.Helpers; + +// From https://github.com/dotnet/runtime/blob/d968dc4bbdc0c26876c2cdaadf42740e891586b9/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/ValueListBuilder.cs#L8 +internal ref partial struct ValueListBuilder +{ + private Span _span; + private T[]? _arrayFromPool; + private int _pos; + + public ValueListBuilder(Span scratchBuffer) + { + _span = scratchBuffer!; + } + + public ValueListBuilder(int capacity) + { + Grow(capacity); + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _span.Length); + _pos = value; + } + } + + public ref T this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _span[index]; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(T item) + { + int pos = _pos; + + // Workaround for https://github.com/dotnet/runtime/issues/72004 + Span span = _span; + if ((uint)pos < (uint)span.Length) + { + span[pos] = item; + _pos = pos + 1; + } + else + { + AddWithResize(item); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(scoped ReadOnlySpan source) + { + int pos = _pos; + Span span = _span; + if (source.Length == 1 && (uint)pos < (uint)span.Length) + { + span[pos] = source[0]; + _pos = pos + 1; + } + else + { + AppendMultiChar(source); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendMultiChar(scoped ReadOnlySpan source) + { + if ((uint)(_pos + source.Length) > (uint)_span.Length) + { + Grow(_span.Length - _pos + source.Length); + } + + source.CopyTo(_span.Slice(_pos)); + _pos += source.Length; + } + + public void Insert(int index, scoped ReadOnlySpan source) + { + Debug.Assert(index == 0, "Implementation currently only supports index == 0"); + + if ((uint)(_pos + source.Length) > (uint)_span.Length) + { + Grow(source.Length); + } + + _span.Slice(0, _pos).CopyTo(_span.Slice(source.Length)); + source.CopyTo(_span); + _pos += source.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + Debug.Assert(length >= 0); + + int pos = _pos; + Span span = _span; + if ((ulong)(uint)pos + (ulong)(uint)length <= (ulong)(uint)span.Length) // same guard condition as in Span.Slice on 64-bit + { + _pos = pos + length; + return span.Slice(pos, length); + } + else + { + return AppendSpanWithGrow(length); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Span AppendSpanWithGrow(int length) + { + int pos = _pos; + Grow(_span.Length - pos + length); + _pos += length; + return _span.Slice(pos, length); + } + + // Hide uncommon path + [MethodImpl(MethodImplOptions.NoInlining)] + private void AddWithResize(T item) + { + Debug.Assert(_pos == _span.Length); + int pos = _pos; + Grow(1); + _span[pos] = item; + _pos = pos + 1; + } + + public ReadOnlySpan AsSpan() + { + return _span.Slice(0, _pos); + } + + public bool TryCopyTo(Span destination, out int itemsWritten) + { + if (_span.Slice(0, _pos).TryCopyTo(destination)) + { + itemsWritten = _pos; + return true; + } + + itemsWritten = 0; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + T[]? toReturn = _arrayFromPool; + if (toReturn != null) + { + _arrayFromPool = null; + +#if SYSTEM_PRIVATE_CORELIB + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + { + ArrayPool.Shared.Return(toReturn, _pos); + } + else + { + ArrayPool.Shared.Return(toReturn); + } +#else + if (!typeof(T).IsPrimitive) + { + Array.Clear(toReturn, 0, _pos); + } + + ArrayPool.Shared.Return(toReturn); +#endif + } + } + + // Note that consuming implementations depend on the list only growing if it's absolutely + // required. If the list is already large enough to hold the additional items be added, + // it must not grow. The list is used in a number of places where the reference is checked + // and it's expected to match the initial reference provided to the constructor if that + // span was sufficiently large. + private void Grow(int additionalCapacityRequired = 1) + { + const int ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Double the size of the span. If it's currently empty, default to size 4, + // although it'll be increased in Rent to the pool's minimum bucket size. + int nextCapacity = Math.Max(_span.Length != 0 ? _span.Length * 2 : 4, _span.Length + additionalCapacityRequired); + + // If the computed doubled capacity exceeds the possible length of an array, then we + // want to downgrade to either the maximum array length if that's large enough to hold + // an additional item, or the current length + 1 if it's larger than the max length, in + // which case it'll result in an OOM when calling Rent below. In the exceedingly rare + // case where _span.Length is already int.MaxValue (in which case it couldn't be a managed + // array), just use that same value again and let it OOM in Rent as well. + if ((uint)nextCapacity > ArrayMaxLength) + { + nextCapacity = Math.Max(Math.Max(_span.Length + 1, ArrayMaxLength), _span.Length); + } + + T[] array = ArrayPool.Shared.Rent(nextCapacity); + _span.CopyTo(array); + + T[]? toReturn = _arrayFromPool; + _span = _arrayFromPool = array; + if (toReturn != null) + { +#if SYSTEM_PRIVATE_CORELIB + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + { + ArrayPool.Shared.Return(toReturn, _pos); + } + else + { + ArrayPool.Shared.Return(toReturn); + } +#else + if (!typeof(T).IsPrimitive) + { + Array.Clear(toReturn, 0, _pos); + } + + ArrayPool.Shared.Return(toReturn); +#endif + } + } +} diff --git a/TUnit.Core/Helpers/ValueStringBuilder.cs b/TUnit.Core/Helpers/ValueStringBuilder.cs index 9bf8a91ed2..13fcbc6187 100644 --- a/TUnit.Core/Helpers/ValueStringBuilder.cs +++ b/TUnit.Core/Helpers/ValueStringBuilder.cs @@ -148,12 +148,12 @@ public void Append(char c) public void Append(int value) => AppendSpanFormattable(value); - #if NET8_0_OR_GREATER +#if NET8_0_OR_GREATER private void AppendSpanFormattable(T value) where T : ISpanFormattable { Debug.Assert(typeof(T).Assembly.Equals(typeof(object).Assembly), "Implementation trusts the results of TryFormat because T is expected to be something known"); - if (value.TryFormat(_chars, out int charsWritten, format: default, provider: null)) + if (value.TryFormat(_chars[_pos..], out int charsWritten, format: default, provider: null)) { _pos += charsWritten; return; diff --git a/TUnit.Engine/Services/TestIdentifierService.cs b/TUnit.Engine/Services/TestIdentifierService.cs index 8a8504fad5..5c6cb5eb46 100644 --- a/TUnit.Engine/Services/TestIdentifierService.cs +++ b/TUnit.Engine/Services/TestIdentifierService.cs @@ -1,14 +1,11 @@ -using System.Buffers; -using System.Text; using TUnit.Core; +using TUnit.Core.Helpers; using TUnit.Engine.Building; namespace TUnit.Engine.Services; internal static class TestIdentifierService { - private const int MaxStackAllocSize = 16; - public static string GenerateTestId(TestMetadata metadata, TestBuilder.TestData combination) { var methodMetadata = metadata.MethodMetadata; @@ -17,62 +14,41 @@ public static string GenerateTestId(TestMetadata metadata, TestBuilder.TestData var constructorParameters = classMetadata.Parameters; var methodParameters = methodMetadata.Parameters; - // Use ArrayPool to avoid heap allocations for Type arrays - // Note: Cannot use stackalloc because Type is a managed reference type - var constructorParameterTypes = ArrayPool.Shared.Rent(constructorParameters.Length); - var methodParameterTypes = ArrayPool.Shared.Rent(methodParameters.Length); + // Use ValueStringBuilder for efficient string concatenation + var vsb = new ValueStringBuilder(stackalloc char[256]); // Pre-size for typical test ID length try { - // Fill arrays with actual types - for (var i = 0; i < constructorParameters.Length; i++) - { - constructorParameterTypes[i] = constructorParameters[i].Type; - } - - for (var i = 0; i < methodParameters.Length; i++) - { - methodParameterTypes[i] = methodParameters[i].Type; - } - - var classTypeWithParameters = BuildTypeWithParameters( - GetTypeNameWithGenerics(metadata.TestClassType), - constructorParameterTypes.AsSpan(0, constructorParameters.Length)); - - var methodWithParameters = BuildTypeWithParameters( - metadata.TestMethodName, - methodParameterTypes.AsSpan(0, methodParameters.Length)); - - // Use StringBuilder for efficient string concatenation - var sb = new StringBuilder(256); // Pre-size for typical test ID length - sb.Append(methodMetadata.Class.Namespace) - .Append('.') - .Append(classTypeWithParameters) - .Append('.') - .Append(combination.ClassDataSourceAttributeIndex) - .Append('.') - .Append(combination.ClassDataLoopIndex) - .Append('.') - .Append(methodWithParameters) - .Append('.') - .Append(combination.MethodDataSourceAttributeIndex) - .Append('.') - .Append(combination.MethodDataLoopIndex) - .Append('.') - .Append(combination.RepeatIndex); + vsb.Append(methodMetadata.Class.Namespace); + vsb.Append('.'); + WriteTypeNameWithGenerics(ref vsb, metadata.TestClassType); + WriteTypeWithParameters(ref vsb, constructorParameters); + vsb.Append('.'); + vsb.Append(combination.ClassDataSourceAttributeIndex); + vsb.Append('.'); + vsb.Append(combination.ClassDataLoopIndex); + vsb.Append('.'); + vsb.Append(metadata.TestMethodName); + WriteTypeWithParameters(ref vsb, methodParameters); + vsb.Append('.'); + vsb.Append(combination.MethodDataSourceAttributeIndex); + vsb.Append('.'); + vsb.Append(combination.MethodDataLoopIndex); + vsb.Append('.'); + vsb.Append(combination.RepeatIndex); // Add inheritance information to ensure uniqueness if (combination.InheritanceDepth > 0) { - sb.Append("_inherited").Append(combination.InheritanceDepth); + vsb.Append("_inherited"); + vsb.Append(combination.InheritanceDepth); } - return sb.ToString(); + return vsb.ToString(); } finally { - ArrayPool.Shared.Return(constructorParameterTypes); - ArrayPool.Shared.Return(methodParameterTypes); + vsb.Dispose(); } } @@ -90,149 +66,125 @@ public static string GenerateFailedTestId(TestMetadata metadata, TestDataCombina var constructorParameters = classMetadata.Parameters; var methodParameters = methodMetadata.Parameters; - // Use ArrayPool to avoid heap allocations for Type arrays - var constructorParameterTypes = ArrayPool.Shared.Rent(constructorParameters.Length); - var methodParameterTypes = ArrayPool.Shared.Rent(methodParameters.Length); + // Use ValueStringBuilder for efficient string concatenation + var vsb = new ValueStringBuilder(stackalloc char[256]); // Pre-size for typical test ID length try { - // Fill arrays with actual types - for (var i = 0; i < constructorParameters.Length; i++) - { - constructorParameterTypes[i] = constructorParameters[i].Type; - } - - for (var i = 0; i < methodParameters.Length; i++) - { - methodParameterTypes[i] = methodParameters[i].Type; - } - - var classTypeWithParameters = BuildTypeWithParameters( - GetTypeNameWithGenerics(metadata.TestClassType), - constructorParameterTypes.AsSpan(0, constructorParameters.Length)); - - var methodWithParameters = BuildTypeWithParameters( - metadata.TestMethodName, - methodParameterTypes.AsSpan(0, methodParameters.Length)); - - // Use StringBuilder for efficient string concatenation - var sb = new StringBuilder(256); // Pre-size for typical test ID length - sb.Append(methodMetadata.Class.Namespace) - .Append('.') - .Append(classTypeWithParameters) - .Append('.') - .Append(combination.ClassDataSourceIndex) - .Append('.') - .Append(combination.ClassLoopIndex) - .Append('.') - .Append(methodWithParameters) - .Append('.') - .Append(combination.MethodDataSourceIndex) - .Append('.') - .Append(combination.MethodLoopIndex) - .Append('.') - .Append(combination.RepeatIndex) - .Append("_DataGenerationError"); - - return sb.ToString(); + vsb.Append(methodMetadata.Class.Namespace); + vsb.Append('.'); + WriteTypeNameWithGenerics(ref vsb, metadata.TestClassType); + WriteTypeWithParameters(ref vsb, constructorParameters); + vsb.Append('.'); + vsb.Append(combination.ClassDataSourceIndex); + vsb.Append('.'); + vsb.Append(combination.ClassLoopIndex); + vsb.Append('.'); + vsb.Append(metadata.TestMethodName); + WriteTypeWithParameters(ref vsb, methodParameters); + vsb.Append('.'); + vsb.Append(combination.MethodDataSourceIndex); + vsb.Append('.'); + vsb.Append(combination.MethodLoopIndex); + vsb.Append('.'); + vsb.Append(combination.RepeatIndex); + vsb.Append("_DataGenerationError"); + + return vsb.ToString(); } finally { - ArrayPool.Shared.Return(constructorParameterTypes); - ArrayPool.Shared.Return(methodParameterTypes); + vsb.Dispose(); } } - private static string GetTypeNameWithGenerics(Type type) + private static void WriteTypeNameWithGenerics(ref ValueStringBuilder vsb, Type type) { - var sb = new StringBuilder(); - // Build the full type hierarchy including all containing types - var typeHierarchy = new List(); - var currentType = type; - - while (currentType != null) + var typeHierarchy = new ValueListBuilder([null, null, null, null]); + var typeVsb = new ValueStringBuilder(stackalloc char[128]); + try { - if (currentType.IsGenericType) - { - var typeSb = new StringBuilder(); - var name = currentType.Name; + var currentType = type; - var backtickIndex = name.IndexOf('`'); - if (backtickIndex > 0) - { -#if NET6_0_OR_GREATER - typeSb.Append(name.AsSpan(0, backtickIndex)); -#else - typeSb.Append(name.Substring(0, backtickIndex)); -#endif - } - else + while (currentType != null) + { + if (currentType.IsGenericType) { - typeSb.Append(name); - } + var name = currentType.Name; - // Add the generic type arguments - var genericArgs = currentType.GetGenericArguments(); - typeSb.Append('<'); - for (var i = 0; i < genericArgs.Length; i++) - { - if (i > 0) + var backtickIndex = name.IndexOf('`'); + if (backtickIndex > 0) + { + typeVsb.Append(name.AsSpan(0, backtickIndex)); + } + else + { + typeVsb.Append(name); + } + + // Add the generic type arguments + var genericArgs = currentType.GetGenericArguments(); + typeVsb.Append('<'); + for (var i = 0; i < genericArgs.Length; i++) { - typeSb.Append(", "); + if (i > 0) + { + typeVsb.Append(", "); + } + // Use the full name for generic arguments to ensure uniqueness + typeVsb.Append(genericArgs[i].FullName ?? genericArgs[i].Name); } - // Use the full name for generic arguments to ensure uniqueness - typeSb.Append(genericArgs[i].FullName ?? genericArgs[i].Name); + typeVsb.Append('>'); + + typeHierarchy.Append(typeVsb.AsSpan().ToString()); + typeVsb.Length = 0; + } + else + { + typeHierarchy.Append(currentType.Name); } - typeSb.Append('>'); - typeHierarchy.Add(typeSb.ToString()); + currentType = currentType.DeclaringType; } - else + + // Reverse to get outer-to-inner order + // Append all types with + separator (matching .NET Type.FullName convention for nested types) + for (var i = typeHierarchy.Length - 1; i >= 0; i--) { - typeHierarchy.Add(currentType.Name); + if (i < typeHierarchy.Length - 1) + { + vsb.Append('+'); + } + vsb.Append(typeHierarchy[i]); } - - currentType = currentType.DeclaringType; } - - // Reverse to get outer-to-inner order - typeHierarchy.Reverse(); - - // Append all types with + separator (matching .NET Type.FullName convention for nested types) - for (var i = 0; i < typeHierarchy.Count; i++) + finally { - if (i > 0) - { - sb.Append('+'); - } - sb.Append(typeHierarchy[i]); + typeHierarchy.Dispose(); + typeVsb.Dispose(); } - - return sb.ToString(); } - private static string BuildTypeWithParameters(string typeName, ReadOnlySpan parameterTypes) + private static void WriteTypeWithParameters(ref ValueStringBuilder vsb, ReadOnlySpan parameterTypes) { if (parameterTypes.Length == 0) { - return typeName; + return; } - // Use StringBuilder for efficient parameter list construction - var sb = new StringBuilder(typeName.Length + parameterTypes.Length * 20); // Estimate capacity - sb.Append(typeName).Append('('); + vsb.Append('('); for (var i = 0; i < parameterTypes.Length; i++) { if (i > 0) { - sb.Append(", "); + vsb.Append(", "); } - sb.Append(parameterTypes[i]); + + vsb.Append(parameterTypes[i].Type.ToString()); } - sb.Append(')'); - return sb.ToString(); + vsb.Append(')'); } }