Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions TextFilter.Test/TextFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -666,4 +666,53 @@ public void AnOrdinaryRegexIsUnaffectedByTheTimeout()
Assert.IsTrue(TextFilter.IsMatch("hello world", "^hello", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));
Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));
}

[TestMethod]
public void TheRegexCacheStaysBoundedAsDistinctPatternsArrive()
{
// The keystroke case from the report: every prefix a user types is a distinct key. Feeding
// more distinct patterns than the cap must not leave more than the cap cached.
for (int i = 0; i < (TextFilter.MaxCacheEntries * 2); i++)
{
TextFilter.IsMatch("hello world", $"^p{i}x", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString);
}

Assert.IsLessThanOrEqualTo(
TextFilter.MaxCacheEntries,
TextFilter.RegexCacheCount,
"The regex cache should be bounded, not grow with every pattern ever seen.");
}

[TestMethod]
public void TheGlobCacheStaysBoundedAsDistinctPatternsArrive()
{
for (int i = 0; i < (TextFilter.MaxCacheEntries * 2); i++)
{
TextFilter.IsMatch("hello world", $"*q{i}z*", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString);
}

Assert.IsLessThanOrEqualTo(
TextFilter.MaxCacheEntries,
TextFilter.GlobCacheCount,
"The glob cache should be bounded, not grow with every pattern ever seen.");
}

[TestMethod]
public void ACachedPatternStillMatchesAfterTheCacheHasReset()
{
// Guards the over-correction: a bound that dropped correctness rather than entries. The same
// pattern must answer identically before and after enough churn to force a reset.
const string pattern = "^hello";
Assert.IsTrue(TextFilter.IsMatch("hello world", pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));

for (int i = 0; i < (TextFilter.MaxCacheEntries + 1); i++)
{
TextFilter.IsMatch("hello world", $"^r{i}y", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString);
}

Assert.IsTrue(
TextFilter.IsMatch("hello world", pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString),
"A pattern evicted by the bound should simply be recompiled, not answer differently.");
Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString));
}
}
29 changes: 27 additions & 2 deletions TextFilter/TextFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,31 @@
private static ConcurrentDictionary<string, Regex> RegexCache { get; } = [];
private static ConcurrentDictionary<string, Glob> GlobCache { get; } = [];

// Filter patterns are caller-supplied, and in the keystroke-driven filter box this library exists
// for, every prefix of what the user types becomes its own key. Unbounded, the caches therefore
// grow with everything ever typed for the lifetime of the process, rather than with the size of
// the data being filtered.
internal const int MaxCacheEntries = 4096;

internal static int RegexCacheCount => RegexCache.Count;

internal static int GlobCacheCount => GlobCache.Count;

// Clear-and-restart rather than LRU: ConcurrentDictionary keeps no eviction order, and tracking
// one would put a write on every cache *hit* — the path the cache exists to keep cheap. This
// only ever runs on a miss, where a compile is about to happen anyway, so the Count (which does
// take the dictionary's locks) is off the hot path. At this cap a reset is rare, and costs one
// recompile per pattern still in use.
private static void AddBounded<TValue>(ConcurrentDictionary<string, TValue> cache, string cacheKey, TValue value)
{
if (cache.Count >= MaxCacheEntries)
{
cache.Clear();
}

cache.TryAdd(cacheKey, value);
}

// Both caches are keyed by pattern text, so the same pattern compiled at two sensitivities would
// otherwise collide on the first one cached. The sensitivity is folded into the key rather than
// using a tuple key, which netstandard2.0 does not get for free.
Expand All @@ -98,7 +123,7 @@
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);

[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")]
private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled);

Check warning on line 126 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 126 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 126 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

Check warning on line 126 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Pass a timeout to limit the execution time.

/// <summary>
/// Gets a hint for the specified filter type.
Expand Down Expand Up @@ -250,7 +275,7 @@

return ExcludedTokenPrefixes.Contains(prefix)
? TextFilterTokenType.Excluded
: RequiredTokenPrefixes.Contains(prefix)

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 278 in TextFilter/TextFilter.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
? TextFilterTokenType.Required
: TextFilterTokenType.Optional;
})
Expand Down Expand Up @@ -379,7 +404,7 @@
? Glob.Parse(filterToken, CaseInsensitiveGlobOptions)
: Glob.Parse(filterToken);

GlobCache.TryAdd(cacheKey, glob);
AddBounded(GlobCache, cacheKey, glob);
}

return glob;
Expand Down Expand Up @@ -423,7 +448,7 @@
regex = RegexMatchAnything();
}

RegexCache.TryAdd(cacheKey, regex);
AddBounded(RegexCache, cacheKey, regex);
}

Func<IEnumerable<string>, Func<string, bool>, bool> matchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny
Expand Down
Loading