diff --git a/CHANGELOG.md b/CHANGELOG.md index 23fc924..e881867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ARM NEON SIMD support (Go 1.26 `simd/archsimd` intrinsics — [#120](https://github.com/coregx/coregex/issues/120)) - SIMD prefilter for CompositeSequenceDFA (#83) +## [0.12.14] - 2026-03-19 + +### Fixed +- **`isMatchDFA` concurrent safety** (Issue [#137](https://github.com/coregx/coregex/issues/137)) — + prefilter candidate loop called `e.dfa.SearchAtAnchored` concurrently from + `RunParallel`. Shared lazy DFA is NOT thread-safe for concurrent lazy state + construction. On ARM64 without SIMD prefilters, every candidate hit DFA → + cache corruption → 1.7GB allocs, 1s+ per op on M2 Max. + Fix: prefilter used for fast rejection only (one `Find` call), verification + falls through to `DFA.IsMatch` (read-mostly when cache warm). + +### Added +- **Concurrent case-insensitive prefilter test** — `TestConcurrentCaseInsensitivePrefilter` + with 8 goroutines × 100 iterations, both match and no-match paths. + ## [0.12.13] - 2026-03-18 ### Performance diff --git a/ROADMAP.md b/ROADMAP.md index 8cdf402..59203d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > **Strategic Focus**: Production-grade regex engine with RE2/rust-regex level optimizations -**Last Updated**: 2026-03-18 | **Current Version**: v0.12.13 | **Target**: v1.0.0 stable +**Last Updated**: 2026-03-19 | **Current Version**: v0.12.14 | **Target**: v1.0.0 stable --- @@ -12,7 +12,7 @@ Build a **production-ready, high-performance regex engine** for Go that matches ### Current State vs Target -| Metric | Current (v0.12.13) | Target (v1.0.0) | +| Metric | Current (v0.12.14) | Target (v1.0.0) | |--------|-------------------|-----------------| | Inner literal speedup | **280-3154x** | ✅ Achieved | | Case-insensitive speedup | **263x** | ✅ Achieved | @@ -82,7 +82,9 @@ v0.12.11 ✅ → ReverseSuffix multi-wildcard, COREGEX_DEBUG logging ↓ v0.12.12 ✅ → Prefix trimming for case-fold literals (Teddy instead of AC) ↓ -v0.12.13 (Current) ✅ → FatTeddy fix, prefilter acceleration, AC v0.2.1 +v0.12.13 ✅ → FatTeddy fix, prefilter acceleration, AC v0.2.1 + ↓ +v0.12.14 (Current) ✅ → Concurrent safety fix for isMatchDFA prefilter (#137) ↓ v1.0.0-rc → Feature freeze, API locked ↓ diff --git a/meta/concurrent_test.go b/meta/concurrent_test.go index b438319..bdf09a4 100644 --- a/meta/concurrent_test.go +++ b/meta/concurrent_test.go @@ -1,6 +1,7 @@ package meta import ( + "strings" "sync" "sync/atomic" "testing" @@ -384,6 +385,77 @@ func TestConcurrentDifferentPatterns(t *testing.T) { wg.Wait() } +// TestConcurrentCaseInsensitivePrefilter verifies that case-insensitive patterns +// with prefilter work correctly under concurrent RunParallel. +// Regression test for Issue #137: on ARM64 without SIMD, concurrent access to +// shared lazy DFA via prefilter candidate loop caused cache corruption +// (1.7GB allocs, 1s+ per op on M2 Max). +func TestConcurrentCaseInsensitivePrefilter(t *testing.T) { + // This pattern produces 60 case-fold prefix literals → FatTeddy/AC prefilter + // + UseDFA strategy with 181 NFA states + engine, err := Compile(`(?iU)\b(eval|system|exec|execute|passthru|shell_exec|phpinfo)\b`) + if err != nil { + t.Fatal(err) + } + + // Input WITH match — exercises verification path, not just rejection + matchInput := []byte(strings.Repeat("Mozilla/5.0 Safari/537.36 phpinfo", 8)) + // Input WITHOUT match — exercises fast rejection path + noMatchInput := []byte(strings.Repeat("Mozilla/5.0 Safari/537.36 Chrome/96", 8)) + + t.Run("match_concurrent", func(t *testing.T) { + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + if !engine.IsMatch(matchInput) { + t.Error("IsMatch returned false, expected true") + return + } + } + }() + } + wg.Wait() + }) + + t.Run("no_match_concurrent", func(t *testing.T) { + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + if engine.IsMatch(noMatchInput) { + t.Error("IsMatch returned true, expected false") + return + } + } + }() + } + wg.Wait() + }) +} + +// BenchmarkConcurrentCaseInsensitiveMatch benchmarks concurrent IsMatch with +// case-insensitive prefilter pattern — the pattern reported in Issue #137. +func BenchmarkConcurrentCaseInsensitiveMatch(b *testing.B) { + engine, err := Compile(`(?iU)\b(eval|system|exec|execute|passthru|shell_exec|phpinfo)\b`) + if err != nil { + b.Fatal(err) + } + + input := []byte(strings.Repeat("Mozilla/5.0 Safari/537.36 phpinfo", 8)) + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + engine.IsMatch(input) + } + }) +} + // BenchmarkConcurrentIsMatch benchmarks concurrent IsMatch performance. func BenchmarkConcurrentIsMatch(b *testing.B) { engine, err := Compile(`\b\w+\b`) diff --git a/meta/ismatch.go b/meta/ismatch.go index cd2b2a8..99b162e 100644 --- a/meta/ismatch.go +++ b/meta/ismatch.go @@ -135,40 +135,28 @@ func (e *Engine) isMatchNFA(haystack []byte) bool { func (e *Engine) isMatchDFA(haystack []byte) bool { atomic.AddUint64(&e.stats.DFASearches, 1) - // Prefilter-accelerated matching: use prefilter to skip non-matching regions, - // then verify each candidate with anchored DFA or PikeVM. - // This is critical for case-insensitive patterns with large NFAs (Issue #137). + // Prefilter fast rejection: if prefilter finds no candidates, no match. + // For incomplete prefilters with candidates, use pooled PikeVM for + // thread-safe verification. Shared lazy DFA (e.dfa) is NOT thread-safe + // for ANY concurrent access — even DFA.IsMatch causes data races on + // lazy state construction. Issue #137 (ARM64 M2 Max: 1.7GB allocs). if e.prefilter != nil { - pos := 0 - for pos < len(haystack) { - candidate := e.prefilter.Find(haystack, pos) - if candidate == -1 { - return false // No more candidates - } - atomic.AddUint64(&e.stats.PrefilterHits, 1) - // For complete prefilters, the find is sufficient - if e.prefilter.IsComplete() { - return true - } - // Verify candidate with anchored DFA (O(pattern_len) per candidate). - // Unanchored verification would scan to end of input = O(n) per candidate. - if e.dfa != nil { - if e.dfa.SearchAtAnchored(haystack, candidate) != -1 { - return true - } - } else { - // PikeVM fallback — check if match starts at candidate position - start, _, found := e.pikevm.SearchAt(haystack, candidate) - if found && start == candidate { - return true - } - } - pos = candidate + 1 + pos := e.prefilter.Find(haystack, 0) + if pos == -1 { + return false // Prefilter says no candidates — fast rejection } - return false + atomic.AddUint64(&e.stats.PrefilterHits, 1) + if e.prefilter.IsComplete() { + return true // Complete prefilter — find is sufficient + } + // Prefilter found candidate but isn't complete — verify with pooled PikeVM + state := e.getSearchState() + _, _, found := state.pikevm.SearchAt(haystack, pos) + e.putSearchState(state) + return found } - // Use DFA.IsMatch which has early termination optimization + // No prefilter: use DFA.IsMatch (single-thread path, safe) return e.dfa.IsMatch(haystack) }