diff --git a/src/TestFramework/TestFramework/Assertions/Assert.Matches.cs b/src/TestFramework/TestFramework/Assertions/Assert.Matches.cs index 09db0ae990..fc03bcd09c 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.Matches.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.Matches.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. namespace Microsoft.VisualStudio.TestTools.UnitTesting; @@ -7,6 +7,12 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting; public sealed partial class Assert { + // Match the runtime's default static Regex cache size while bounding retained user-provided pattern text. + private const int RegexCacheSize = 15; + private const int MaximumCachedRegexPatternLength = 512; + + private static readonly BoundedRegexCache RegexCache = new(); + #region MatchesRegex /// @@ -195,9 +201,79 @@ public static void DoesNotMatchRegex([NotNull] string? pattern, [NotNull] string #endregion // DoesNotMatchRegex - private static Regex? ToRegex([NotNull] string? pattern) + private static Regex ToRegex([NotNull] string? pattern) { CheckParameterNotNull(pattern, "Assert.MatchesRegex", "pattern"); - return new Regex(pattern); + if (pattern.Length > MaximumCachedRegexPatternLength) + { + return new Regex(pattern); + } + + string cultureName = CultureInfo.CurrentCulture.Name; + if (RegexCache.TryGet(pattern, cultureName, out Regex cachedRegex)) + { + return cachedRegex; + } + + Regex regex = new(pattern); + return RegexCache.AddOrGetExisting(pattern, cultureName, regex); + } + + private sealed class BoundedRegexCache + { +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + + private readonly RegexCacheEntry?[] _entries = new RegexCacheEntry[RegexCacheSize]; + + private int _nextInsertionIndex; + + public bool TryGet(string pattern, string cultureName, out Regex regex) + { + int nextInsertionIndex = Volatile.Read(ref _nextInsertionIndex); + for (int offset = 1; offset <= RegexCacheSize; offset++) + { + int index = (nextInsertionIndex - offset + RegexCacheSize) % RegexCacheSize; + RegexCacheEntry? entry = Volatile.Read(ref _entries[index]); + if (entry is not null + && string.Equals(entry.Pattern, pattern, StringComparison.Ordinal) + && string.Equals(entry.CultureName, cultureName, StringComparison.Ordinal)) + { + regex = entry.Regex; + return true; + } + } + + regex = null!; + return false; + } + + public Regex AddOrGetExisting(string pattern, string cultureName, Regex regex) + { + lock (_lock) + { + if (TryGet(pattern, cultureName, out Regex cachedRegex)) + { + return cachedRegex; + } + + int insertionIndex = _nextInsertionIndex; + Volatile.Write(ref _entries[insertionIndex], new RegexCacheEntry(pattern, cultureName, regex)); + Volatile.Write(ref _nextInsertionIndex, (insertionIndex + 1) % RegexCacheSize); + return regex; + } + } + } + + private sealed class RegexCacheEntry(string pattern, string cultureName, Regex regex) + { + public string Pattern { get; } = pattern; + + public string CultureName { get; } = cultureName; + + public Regex Regex { get; } = regex; } } diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.MatchesRegex.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.MatchesRegex.cs index 0bb846f337..b8788aa3cf 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.MatchesRegex.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.MatchesRegex.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Text.RegularExpressions; @@ -9,6 +9,21 @@ namespace Microsoft.VisualStudio.TestPlatform.TestFramework.UnitTests; public partial class AssertTests { + private static readonly int RegexCacheSize = + (int)typeof(Assert) + .GetField("RegexCacheSize", BindingFlags.Static | BindingFlags.NonPublic)! + .GetRawConstantValue()!; + + private static readonly int MaximumCachedRegexPatternLength = + (int)typeof(Assert) + .GetField("MaximumCachedRegexPatternLength", BindingFlags.Static | BindingFlags.NonPublic)! + .GetRawConstantValue()!; + + private static readonly Func ToRegex = + (Func)typeof(Assert) + .GetMethod("ToRegex", BindingFlags.Static | BindingFlags.NonPublic)! + .CreateDelegate(typeof(Func)); + public void MatchesRegex_WithRegexPattern_OnSuccess_DoesNotThrow() => FluentActions.Invoking(() => Assert.MatchesRegex(new Regex("^he"), "hello")) .Should().NotThrow(); @@ -17,6 +32,162 @@ public void MatchesRegex_WithStringPattern_OnSuccess_DoesNotThrow() => FluentActions.Invoking(() => Assert.MatchesRegex("^he", "hello")) .Should().NotThrow(); + public void MatchesRegex_WithRepeatedStringPattern_ReusesRegex() + { + string pattern = $"^{Guid.NewGuid():N}$"; + + ToRegex(pattern).Should().BeSameAs(ToRegex(pattern)); + } + + public void MatchesRegex_WithCaseDistinctStringPatterns_DoesNotReuseRegex() + { + string patternPrefix = Guid.NewGuid().ToString("N"); + string lowercasePattern = $"^{patternPrefix}-a$"; + string uppercasePattern = $"^{patternPrefix}-A$"; + + ToRegex(lowercasePattern).Should().NotBeSameAs(ToRegex(uppercasePattern)); + } + + public void MatchesRegex_WhenOldestRegexIsReusedBeforeCapacityIsExceeded_StillEvictsOldestRegex() + { + string patternPrefix = Guid.NewGuid().ToString("N"); + string oldestPattern = $"^{patternPrefix}-oldest$"; + Regex oldestRegex = ToRegex(oldestPattern); + + for (int i = 0; i < RegexCacheSize - 1; i++) + { + _ = ToRegex($"^{patternPrefix}-{i}$"); + } + + ToRegex(oldestPattern).Should().BeSameAs(oldestRegex); + _ = ToRegex($"^{patternPrefix}-newest$"); + + ToRegex(oldestPattern).Should().NotBeSameAs(oldestRegex); + } + + public void MatchesRegex_WithMaximumLengthStringPattern_ReusesRegex() + { + string pattern = new('a', MaximumCachedRegexPatternLength); + + ToRegex(pattern).Should().BeSameAs(ToRegex(pattern)); + } + + public void MatchesRegex_WithOverMaximumLengthStringPattern_DoesNotCacheRegex() + { + string pattern = new('a', MaximumCachedRegexPatternLength + 1); + + ToRegex(pattern).Should().NotBeSameAs(ToRegex(pattern)); + } + + public void MatchesRegex_WithCultureSensitivePattern_DoesNotReuseRegexAcrossCultures() + { + const string Pattern = "(?i)^i$"; + CultureInfo originalCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + Regex enUsRegex = ToRegex(Pattern); + enUsRegex.IsMatch("I").Should().BeTrue(); + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + Regex trTrRegex = ToRegex(Pattern); + trTrRegex.Should().NotBeSameAs(enUsRegex); + trTrRegex.IsMatch("I").Should().BeFalse(); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + public void MatchesRegex_WithConcurrentCandidateRegexes_ConvergesOnSingleCachedRegex() + { + string pattern = $"^{Guid.NewGuid():N}$"; + string cultureName = CultureInfo.CurrentCulture.Name; + const int ThreadCount = 8; + var regexes = new Regex[ThreadCount]; + var exceptions = new Exception?[ThreadCount]; + using var ready = new CountdownEvent(ThreadCount); + using var start = new ManualResetEventSlim(); + Type cacheType = typeof(Assert).GetNestedType("BoundedRegexCache", BindingFlags.NonPublic)!; + object cache = Activator.CreateInstance(cacheType, nonPublic: true)!; + var addOrGetExisting = (Func)cacheType + .GetMethod("AddOrGetExisting", BindingFlags.Instance | BindingFlags.Public)! + .CreateDelegate(typeof(Func), cache); + var threads = new Thread[ThreadCount]; + + for (int i = 0; i < threads.Length; i++) + { + int index = i; + threads[i] = new Thread(() => + { + try + { + var candidate = new Regex(pattern); + ready.Signal(); + start.Wait(); + regexes[index] = addOrGetExisting(pattern, cultureName, candidate); + } + catch (Exception ex) + { + exceptions[index] = ex; + } + }); + threads[i].Start(); + } + + ready.Wait(); + start.Set(); + + foreach (Thread thread in threads) + { + thread.Join(); + } + + exceptions.Should().BeEquivalentTo(new Exception?[ThreadCount]); + regexes.Should().OnlyContain(regex => ReferenceEquals(regexes[0], regex)); + } + + public void MatchesRegex_WithInvalidStringPatternAndNullValue_ThrowsPatternExceptionFirst() + { + Action action = () => Assert.MatchesRegex("[", null); + + action.Should().Throw(); + } + + public void DoesNotMatchRegex_WithInvalidStringPatternAndNullValue_ThrowsPatternExceptionFirst() + { + Action action = () => Assert.DoesNotMatchRegex("[", null); + + action.Should().Throw(); + } + + public void MatchesRegex_WithValidStringPatternAndNullValue_ThrowsAssertFailedException() + { + Action action = () => Assert.MatchesRegex("valid", null); + + action.Should().Throw(); + } + + public void DoesNotMatchRegex_WithValidStringPatternAndNullValue_ThrowsAssertFailedException() + { + Action action = () => Assert.DoesNotMatchRegex("valid", null); + + action.Should().Throw(); + } + + public void MatchesRegex_WithRegexPattern_BypassesStringPatternCache() + { + const string Pattern = "^abc$"; + Regex cachedRegex = ToRegex(Pattern); + var suppliedRegex = new Regex(Pattern, RegexOptions.IgnoreCase); + + Assert.MatchesRegex(suppliedRegex, "ABC"); + ToRegex(Pattern).Should().BeSameAs(cachedRegex); + suppliedRegex.Should().NotBeSameAs(cachedRegex); + } + public void MatchesRegex_WithRegexPattern_OnFailure_UsesStructuredMessageAndPayload() { Action action = () => Assert.MatchesRegex(new Regex("^foo"), "hello", "User-provided message");