Skip to content

Instantly share code, notes, and snippets.

@nd2408
Created January 25, 2024 08:42
Show Gist options
  • Save nd2408/5292e4a7392ebb7e75c2a9dda9505728 to your computer and use it in GitHub Desktop.
Save nd2408/5292e4a7392ebb7e75c2a9dda9505728 to your computer and use it in GitHub Desktop.
fsnotify wrapper
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
watcher, err := watcher.New(logger)
defer watcher.Shutdown()
err = watcher.Watch("/home/user", func(e watcher.Event) error {
// only files in /home/user
return nil
})
err = watcher.Watch("/home/user2/*", func(e watcher.Event) error {
// files and subdirectories of /home/user2
return nil
})
package watcher
import (
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
func New(log *slog.Logger) (*Watcher, error) {
var err error
s := &Watcher{
log: log.With("service", "watcher"),
paths: make(map[string]WatcherFunc),
cooldown: time.Millisecond * 250,
}
s.fsw, err = fsnotify.NewWatcher()
if err != nil {
return nil, err
}
go s.runLoop()
return s, nil
}
type Event struct {
Path string
Op string
}
func (s Event) String() string {
return s.Op + " - " + s.Path
}
type WatcherFunc func(Event) error
type Watcher struct {
log *slog.Logger
fsw *fsnotify.Watcher
paths map[string]WatcherFunc
mu sync.Mutex
cooldown time.Duration
}
func (s *Watcher) addSubDirs(root string) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
return s.fsw.Add(path)
})
}
func (s *Watcher) Watch(path string, fn WatcherFunc) (err error) {
s.mu.Lock()
defer s.mu.Unlock()
dir, file := filepath.Split(path)
if file == "*" {
err = s.addSubDirs(dir)
} else {
err = s.fsw.Add(path)
}
if err == nil {
s.paths[strings.TrimSuffix(path, "*")] = fn
}
return err
}
func (s *Watcher) Shutdown() {
_ = s.fsw.Close()
}
func (s *Watcher) runLoop() {
ts := time.Now()
for {
select {
case err, ok := <-s.fsw.Errors:
if !ok {
return
}
s.log.Error(err.Error())
case event, ok := <-s.fsw.Events:
if !ok {
return
}
if isCreateDir(event) {
if err := s.fsw.Add(event.Name); err != nil {
s.log.Error("failed to watch created dir", slog.String("error", err.Error()))
}
continue
}
if time.Since(ts) < s.cooldown {
continue
}
ts = time.Now()
s.runFunc(event)
}
}
}
func (s *Watcher) runFunc(event fsnotify.Event) {
s.mu.Lock()
defer s.mu.Unlock()
for path, fn := range s.paths {
if !strings.HasPrefix(event.Name, path) {
continue
}
s.log.Info(
"file change detected",
slog.String("path", event.Name),
slog.String("op", event.Op.String()),
)
err := fn(Event{
Path: event.Name,
Op: event.Op.String(),
})
if err != nil {
s.log.Error(
"error running watcher func",
slog.String("path", event.Name),
slog.String("op", event.Op.String()),
slog.String("error", err.Error()),
)
}
return
}
}
func isCreateDir(event fsnotify.Event) bool {
if !event.Op.Has(fsnotify.Create) {
return false
}
fi, err := os.Stat(event.Name)
if err != nil {
return false
}
return fi.IsDir()
}
@nd2408
Copy link
Author

nd2408 commented Jan 25, 2024

be warned about the simple logic in runFunc(). if you need to watch /home and /home/user with different WatcherFunc's, the path's map range is random order, so they will get mixed up half the time. it's a non-issue for my use case so i kept it simple but could throw someone for a loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment