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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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 |
Expand Down Expand Up @@ -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
↓
Expand Down
72 changes: 72 additions & 0 deletions meta/concurrent_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package meta

import (
"strings"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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`)
Expand Down
48 changes: 18 additions & 30 deletions meta/ismatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading