diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 21:59:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 21:59:10 +0300 |
| commit | 103f369cee209f6f0a15ef953cff138ffa025a26 (patch) | |
| tree | ba9ee209acab34726fc1d43753aee18bc98a46d0 | |
| parent | bcfeb2deb2fc089c81971744774095409ad12a43 (diff) | |
task b: protect regex cache with RWMutex
| -rw-r--r-- | internal/ui/handlers.go | 4 | ||||
| -rw-r--r-- | internal/ui/helpers.go | 57 | ||||
| -rw-r--r-- | internal/ui/helpers_test.go | 49 | ||||
| -rw-r--r-- | internal/ui/table.go | 2 |
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 { |
