summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 21:59:10 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 21:59:10 +0300
commit103f369cee209f6f0a15ef953cff138ffa025a26 (patch)
treeba9ee209acab34726fc1d43753aee18bc98a46d0
parentbcfeb2deb2fc089c81971744774095409ad12a43 (diff)
task b: protect regex cache with RWMutex
-rw-r--r--internal/ui/handlers.go4
-rw-r--r--internal/ui/helpers.go57
-rw-r--r--internal/ui/helpers_test.go49
-rw-r--r--internal/ui/table.go2
4 files changed, 88 insertions, 24 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 0e41272..971561e 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -403,7 +403,7 @@ func (m *Model) handleSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
pattern := m.searchInput.Value()
if pattern != "" {
// Check cache first
- if cached, ok := searchRegexCache[pattern]; ok {
+ if cached, ok := cachedSearchRegex(pattern); ok {
m.searchRegex = cached
} else {
// Compile and cache if not found
@@ -454,7 +454,7 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
pattern := m.helpSearchInput.Value()
if pattern != "" {
// Check cache first
- if cached, ok := searchRegexCache[pattern]; ok {
+ if cached, ok := cachedSearchRegex(pattern); ok {
m.helpSearchRegex = cached
} else {
// Compile and cache if not found
diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go
index 6cefa96..8797ef9 100644
--- a/internal/ui/helpers.go
+++ b/internal/ui/helpers.go
@@ -38,12 +38,12 @@ func formatDueText(dueStr string) string {
if dueStr == "" {
return ""
}
-
+
ts, err := parseTaskDate(dueStr)
if err != nil {
return dueStr
}
-
+
days := daysUntil(ts)
switch days {
case 0:
@@ -63,15 +63,28 @@ func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) {
if err != nil {
return nil, err
}
-
- // Limit cache size to prevent memory leak
+ storeSearchRegex(pattern, re)
+ return re, nil
+}
+
+// cachedSearchRegex returns a compiled regex from the cache if present.
+func cachedSearchRegex(pattern string) (*regexp.Regexp, bool) {
+ searchRegexMu.RLock()
+ re, ok := searchRegexCache[pattern]
+ searchRegexMu.RUnlock()
+ return re, ok
+}
+
+// storeSearchRegex records a compiled regex in the cache.
+func storeSearchRegex(pattern string, re *regexp.Regexp) {
+ searchRegexMu.Lock()
+ defer searchRegexMu.Unlock()
+
+ // Limit cache size to prevent memory leak.
if len(searchRegexCache) > 100 {
- // Clear cache when it gets too large
searchRegexCache = make(map[string]*regexp.Regexp)
}
searchRegexCache[pattern] = re
-
- return re, nil
}
// parseFilterInput splits a raw filter string typed by the user into the
@@ -102,15 +115,15 @@ func validateTagName(tag string) error {
if tag == "" {
return fmt.Errorf("tag cannot be empty")
}
-
+
// Remove leading + or - for validation
tag = strings.TrimPrefix(strings.TrimPrefix(tag, "+"), "-")
-
+
// Check for invalid characters
if strings.ContainsAny(tag, " \t\n\r") {
return fmt.Errorf("tag cannot contain whitespace")
}
-
+
return nil
}
@@ -119,7 +132,7 @@ func validateDueDate(due string) error {
if due == "" {
return nil // Empty due date is valid
}
-
+
// Try common formats
formats := []string{
"2006-01-02",
@@ -127,24 +140,24 @@ func validateDueDate(due string) error {
"2006-01-02T15:04:05Z",
taskDateFormat,
}
-
+
for _, format := range formats {
if _, err := time.Parse(format, due); err == nil {
return nil
}
}
-
+
// Check for relative dates that taskwarrior understands
- relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday",
+ relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday",
"wednesday", "thursday", "friday", "saturday", "sunday", "eod", "eow", "eom", "eoy"}
-
+
due = strings.ToLower(due)
for _, rel := range relatives {
if due == rel || strings.HasPrefix(due, rel+"+") || strings.HasPrefix(due, rel+"-") {
return nil
}
}
-
+
return fmt.Errorf("invalid due date format: %s", due)
}
@@ -163,12 +176,12 @@ func validateRecurrence(recur string) error {
if recur == "" {
return nil // Empty recurrence is valid
}
-
+
// Basic validation - taskwarrior will do the full validation
if len(recur) < 2 {
return fmt.Errorf("recurrence too short")
}
-
+
// Check for common patterns
validPrefixes := []string{"daily", "weekly", "monthly", "yearly", "biweekly", "bimonthly"}
for _, prefix := range validPrefixes {
@@ -176,16 +189,16 @@ func validateRecurrence(recur string) error {
return nil
}
}
-
+
// Check for duration format (e.g., "3d", "2w", "1m")
if len(recur) >= 2 {
last := recur[len(recur)-1]
- if (last == 'd' || last == 'w' || last == 'm' || last == 'y') &&
+ if (last == 'd' || last == 'w' || last == 'm' || last == 'y') &&
recur[:len(recur)-1] != "" {
return nil
}
}
-
+
return nil // Let taskwarrior handle complex validation
}
@@ -195,4 +208,4 @@ func validateDescription(desc string) error {
return fmt.Errorf("description cannot be empty")
}
return nil
-} \ No newline at end of file
+}
diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go
index 9760277..1fe853b 100644
--- a/internal/ui/helpers_test.go
+++ b/internal/ui/helpers_test.go
@@ -3,7 +3,9 @@ package ui
import (
"fmt"
"reflect"
+ "regexp"
"strings"
+ "sync"
"testing"
"time"
@@ -98,6 +100,52 @@ func TestFormatDueText(t *testing.T) {
}
}
+func TestSearchRegexCacheConcurrentAccess(t *testing.T) {
+ searchRegexMu.Lock()
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ searchRegexMu.Unlock()
+ t.Cleanup(func() {
+ searchRegexMu.Lock()
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ searchRegexMu.Unlock()
+ })
+
+ patterns := []string{`alpha`, `beta`}
+ var wg sync.WaitGroup
+ errCh := make(chan error, 128)
+
+ for i := 0; i < 16; i++ {
+ for _, pattern := range patterns {
+ wg.Add(1)
+ go func(pattern string) {
+ defer wg.Done()
+
+ re, err := compileAndCacheRegex(pattern)
+ if err != nil {
+ errCh <- err
+ return
+ }
+ if re == nil || !re.MatchString(pattern) {
+ errCh <- fmt.Errorf("compiled regex for %q did not match", pattern)
+ return
+ }
+ if cached, ok := cachedSearchRegex(pattern); !ok || cached == nil {
+ errCh <- fmt.Errorf("missing cached regex for %q", pattern)
+ }
+ }(pattern)
+ }
+ }
+
+ wg.Wait()
+ close(errCh)
+
+ for err := range errCh {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
func TestValidateTagName(t *testing.T) {
tests := []struct {
name string
@@ -414,6 +462,7 @@ func TestValidateRecurrence(t *testing.T) {
})
}
}
+
// TestParseFilterInput verifies that parseFilterInput correctly handles
// taskwarrior filter expressions, including attribute filters (proj:xxx),
// tag filters (+tag), quoted values (description:"some text"), and empty input.
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 03c4774..1314eb1 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -7,6 +7,7 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
"github.com/charmbracelet/x/ansi"
@@ -26,6 +27,7 @@ var priorityOptions = []string{"H", "M", "L", ""}
var (
urlRegex = regexp.MustCompile(`https?://\S+`)
searchRegexCache = make(map[string]*regexp.Regexp, 16)
+ searchRegexMu sync.RWMutex
)
type cellMatch struct {