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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
go-version: ['1.21', '1.22', '1.23']
go-version: ['1.24', '1.25', '1.26']

steps:
- uses: actions/checkout@v4
Expand Down
10 changes: 9 additions & 1 deletion watch/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"

"codemap/limits"
Expand All @@ -23,6 +24,8 @@ type Daemon struct {
eventLog string // path to event log file
verbose bool
done chan struct{}

eventLoopWG sync.WaitGroup
}

// NewDaemon creates a new watch daemon for the given root
Expand Down Expand Up @@ -97,7 +100,11 @@ func (d *Daemon) Start() error {
d.writeState()

// Start event loop
go d.eventLoop()
d.eventLoopWG.Add(1)
go func() {
defer d.eventLoopWG.Done()
d.eventLoop()
}()

return nil
}
Expand All @@ -106,6 +113,7 @@ func (d *Daemon) Start() error {
func (d *Daemon) Stop() {
close(d.done)
d.watcher.Close()
d.eventLoopWG.Wait()
}

// GetGraph returns the current graph (thread-safe)
Expand Down
161 changes: 142 additions & 19 deletions watch/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,22 @@ type eventDebouncer struct {
pruneAfter time.Duration
lastSeen map[string]time.Time
lastPruned time.Time
pending map[string]pendingWrite
}

type pendingWrite struct {
event fsnotify.Event
due time.Time
}

type debounceAction uint8

const (
debounceProcess debounceAction = iota
debounceSkip
debounceDefer
)

func newEventDebouncer(window time.Duration) *eventDebouncer {
pruneAfter := 10 * window
if pruneAfter < time.Second {
Expand All @@ -36,6 +50,7 @@ func newEventDebouncer(window time.Duration) *eventDebouncer {
window: window,
pruneAfter: pruneAfter,
lastSeen: make(map[string]time.Time),
pending: make(map[string]pendingWrite),
}
}

Expand All @@ -55,17 +70,15 @@ func (d *eventDebouncer) shouldSkip(event fsnotify.Event, now time.Time) bool {
return false
}

if last, exists := d.lastSeen[event.Name]; exists && now.Sub(last) < d.window {
return true
}
last, exists := d.lastSeen[event.Name]
d.lastSeen[event.Name] = now

if d.lastPruned.IsZero() || now.Sub(d.lastPruned) >= d.pruneAfter {
d.prune(now)
d.lastPruned = now
}

return false
return exists && now.Sub(last) < d.window
}

func (d *eventDebouncer) prune(now time.Time) {
Expand All @@ -77,19 +90,111 @@ func (d *eventDebouncer) prune(now time.Time) {
}
}

func (d *eventDebouncer) deferEvent(event fsnotify.Event, now time.Time) {
d.pending[event.Name] = pendingWrite{event: event, due: now.Add(d.window)}
}

func (d *eventDebouncer) cancelPending(path string) {
delete(d.pending, path)
}

func (d *eventDebouncer) takeDue(now time.Time) []fsnotify.Event {
var events []fsnotify.Event
for path, pending := range d.pending {
if pending.due.After(now) {
continue
}
events = append(events, pending.event)
delete(d.pending, path)
}
return events
}

func (d *eventDebouncer) takeDueBeforeEvent(event fsnotify.Event, now time.Time) []fsnotify.Event {
if event.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 {
d.cancelPending(event.Name)
}
return d.takeDue(now)
}

func (d *eventDebouncer) takeAll() []fsnotify.Event {
events := make([]fsnotify.Event, 0, len(d.pending))
for path, pending := range d.pending {
events = append(events, pending.event)
delete(d.pending, path)
}
return events
}

func (d *eventDebouncer) nextDelay(now time.Time) (time.Duration, bool) {
var earliest time.Time
for _, pending := range d.pending {
if earliest.IsZero() || pending.due.Before(earliest) {
earliest = pending.due
}
}
if earliest.IsZero() {
return 0, false
}
delay := earliest.Sub(now)
if delay < 0 {
delay = 0
}
return delay, true
}

// eventLoop processes file system events
func (d *Daemon) eventLoop() {
debouncer := newEventDebouncer(100 * time.Millisecond)
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()

var timerC <-chan time.Time
armTimer := func(now time.Time) {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
delay, ok := debouncer.nextDelay(now)
if !ok {
timerC = nil
return
}
timer.Reset(delay)
timerC = timer.C
}
flushDue := func(now time.Time) {
for _, event := range debouncer.takeDue(now) {
d.handleEvent(event)
}
}
defer func() {
for _, event := range debouncer.takeAll() {
d.handleEvent(event)
}
}()

for {
select {
case <-d.done:
return

case <-timerC:
now := time.Now()
flushDue(now)
armTimer(time.Now())

case event, ok := <-d.watcher.Events:
if !ok {
return
}
now := time.Now()
for _, pending := range debouncer.takeDueBeforeEvent(event, now) {
d.handleEvent(pending)
}

// Allow directory creates through (to add new dirs to watcher)
// but skip non-source files otherwise
Expand All @@ -114,12 +219,15 @@ func (d *Daemon) eventLoop() {
continue
}

if d.shouldSkipEvent(debouncer, event, time.Now()) {
continue
switch d.debounceAction(debouncer, event, now) {
case debounceSkip:
debouncer.cancelPending(event.Name)
case debounceDefer:
debouncer.deferEvent(event, now)
case debounceProcess:
d.handleEvent(event)
}

// Process the event
d.handleEvent(event)
armTimer(time.Now())

case err, ok := <-d.watcher.Errors:
if !ok {
Expand All @@ -132,24 +240,35 @@ func (d *Daemon) eventLoop() {
}
}

func (d *Daemon) shouldSkipEvent(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) bool {
func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) debounceAction {
if !debouncer.shouldSkip(event, now) {
return false
}
info, err := os.Stat(event.Name)
if err != nil {
return false
return debounceProcess
}
relPath, err := filepath.Rel(d.root, event.Name)
if err != nil {
return false
return debounceProcess
}

d.graph.mu.RLock()
cached := d.graph.State[relPath]
matches := cached != nil && cached.Size == info.Size()
var cachedSize, cachedModTime int64
if cached != nil {
cachedSize = cached.Size
cachedModTime = cached.ModTime
}
d.graph.mu.RUnlock()
return matches
if cached == nil {
return debounceProcess
}

info, err := os.Stat(event.Name)
if err != nil {
return debounceProcess
}
if cachedModTime != 0 && cachedSize == info.Size() && cachedModTime == info.ModTime().UnixNano() {
return debounceSkip
}
return debounceDefer
}

// isSourceFile checks if a file should be tracked.
Expand Down Expand Up @@ -262,7 +381,11 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) {
}

// Update cached state
d.graph.State[relPath] = &FileState{Lines: newLines, Size: info.Size()}
d.graph.State[relPath] = &FileState{
Lines: newLines,
Size: info.Size(),
ModTime: info.ModTime().UnixNano(),
}

// Update file info
d.graph.Files[relPath] = &scanner.FileInfo{
Expand Down
Loading
Loading