Commit c097185877

c0971858774b369ce0c3928bcd593ce36aff92e9

parent: 846c4cbe36

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 18:49 UTC

Close watcher gaps

- Event overflow reschedules a rescan instead of only logging.
- Start watching before the first scan; serialise rescans with a mutex.
- Refuse to start when the cache directory is inside the photo root.

Closes #3

Layout: unified · split

cmd/gallery/main.go +29 −4
@@ -11,6 +11,8 @@ import (
11 "os/signal" 11 "os/signal"
12 "path/filepath" 12 "path/filepath"
13 "runtime" 13 "runtime"
14 "strings"
15 "sync"
14 "syscall" 16 "syscall"
15 "time" 17 "time"
16 18
@@ -27,6 +29,23 @@ func env(key, def string) string {
27 return def 29 return def
28} 30}
29 31
32// checkDirs rejects a cache inside the photo root: every scan rewrites
33// meta.json, which the watcher would report as a change, forever.
34func checkDirs(photos, cache string) error {
35 p, err := filepath.Abs(photos)
36 if err != nil {
37 return err
38 }
39 c, err := filepath.Abs(cache)
40 if err != nil {
41 return err
42 }
43 if rel, err := filepath.Rel(p, c); err == nil && (rel == "." || !strings.HasPrefix(rel, "..")) {
44 return fmt.Errorf("cache %s must not be inside photos %s", cache, photos)
45 }
46 return nil
47}
48
30func main() { 49func main() {
31 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)") 50 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
32 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)") 51 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
@@ -40,6 +59,9 @@ func main() {
40 flag.Usage() 59 flag.Usage()
41 os.Exit(2) 60 os.Exit(2)
42 } 61 }
62 if err := checkDirs(*photos, *cache); err != nil {
63 log.Fatal(err)
64 }
43 if err := format.CheckTools(); err != nil { 65 if err := format.CheckTools(); err != nil {
44 log.Fatal(err) 66 log.Fatal(err)
45 } 67 }
@@ -51,7 +73,10 @@ func main() {
51 } 73 }
52 rend := render.New(*cache, runtime.NumCPU()) 74 rend := render.New(*cache, runtime.NumCPU())
53 store := &library.Store{} 75 store := &library.Store{}
76 var scanMu sync.Mutex
54 rescan := func() error { 77 rescan := func() error {
78 scanMu.Lock()
79 defer scanMu.Unlock()
55 lib, err := library.Scan(*photos, reg, meta) 80 lib, err := library.Scan(*photos, reg, meta)
56 if err != nil { 81 if err != nil {
57 return err 82 return err
@@ -65,10 +90,6 @@ func main() {
65 log.Printf("library: %d albums", len(lib.Albums)) 90 log.Printf("library: %d albums", len(lib.Albums))
66 return nil 91 return nil
67 } 92 }
68 if err := rescan(); err != nil {
69 log.Fatal(err)
70 }
71
72 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 93 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
73 defer stop() 94 defer stop()
74 onChange := func() { 95 onChange := func() {
@@ -76,9 +97,13 @@ func main() {
76 log.Printf("rescan: %v", err) 97 log.Printf("rescan: %v", err)
77 } 98 }
78 } 99 }
100 // Watch before the first scan so files added during it are not missed.
79 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { 101 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
80 log.Fatal(err) 102 log.Fatal(err)
81 } 103 }
104 if err := rescan(); err != nil {
105 log.Fatal(err)
106 }
82 107
83 opt := web.Options{Title: *title, ThemePath: *theme} 108 opt := web.Options{Title: *title, ThemePath: *theme}
84 if *templates != "" { 109 if *templates != "" {
cmd/gallery/main_test.go added +27
@@ -0,0 +1,27 @@
1package main
2
3import (
4 "path/filepath"
5 "testing"
6)
7
8func TestCheckDirs(t *testing.T) {
9 root := t.TempDir()
10 photos := filepath.Join(root, "photos")
11 for _, tc := range []struct {
12 cache string
13 ok bool
14 }{
15 {filepath.Join(root, "cache"), true},
16 {filepath.Join(root, "photos-cache"), true},
17 {photos, false},
18 {filepath.Join(photos, "cache"), false},
19 {filepath.Join(photos, ".cache"), false},
20 {filepath.Join(photos, "a", "..", "cache"), false},
21 } {
22 err := checkDirs(photos, tc.cache)
23 if (err == nil) != tc.ok {
24 t.Errorf("checkDirs(%q, %q) = %v, want ok=%v", photos, tc.cache, err, tc.ok)
25 }
26 }
27}
internal/library/watch.go +33 −23
@@ -2,6 +2,7 @@ package library
2 2
3import ( 3import (
4 "context" 4 "context"
5 "errors"
5 "log" 6 "log"
6 "os" 7 "os"
7 "path/filepath" 8 "path/filepath"
@@ -23,35 +24,44 @@ func Watch(ctx context.Context, root string, debounce time.Duration, onChange fu
23 } 24 }
24 go func() { 25 go func() {
25 defer w.Close() 26 defer w.Close()
26 timer := time.NewTimer(debounce) 27 watchLoop(ctx, w.Events, w.Errors, w.Add, debounce, onChange)
27 timer.Stop() 28 }()
28 for { 29 return nil
29 select { 30}
30 case <-ctx.Done(): 31
32// watchLoop debounces events into onChange calls and watches newly created directories.
33// An event overflow means changes were dropped, so it schedules a rescan too.
34func watchLoop(ctx context.Context, events <-chan fsnotify.Event, errs <-chan error, add func(string) error, debounce time.Duration, onChange func()) {
35 timer := time.NewTimer(debounce)
36 timer.Stop()
37 for {
38 select {
39 case <-ctx.Done():
40 return
41 case ev, ok := <-events:
42 if !ok {
31 return 43 return
32 case ev, ok := <-w.Events: 44 }
33 if !ok { 45 if ev.Has(fsnotify.Create) {
34 return 46 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
35 } 47 if err := add(ev.Name); err != nil {
36 if ev.Has(fsnotify.Create) { 48 log.Printf("watch %s: %v", ev.Name, err)
37 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
38 if err := w.Add(ev.Name); err != nil {
39 log.Printf("watch %s: %v", ev.Name, err)
40 }
41 } 49 }
42 } 50 }
51 }
52 timer.Reset(debounce)
53 case err, ok := <-errs:
54 if !ok {
55 return
56 }
57 log.Printf("watch: %v", err)
58 if errors.Is(err, fsnotify.ErrEventOverflow) {
43 timer.Reset(debounce) 59 timer.Reset(debounce)
44 case err, ok := <-w.Errors:
45 if !ok {
46 return
47 }
48 log.Printf("watch: %v", err)
49 case <-timer.C:
50 onChange()
51 } 60 }
61 case <-timer.C:
62 onChange()
52 } 63 }
53 }() 64 }
54 return nil
55} 65}
56 66
57func addTree(w *fsnotify.Watcher, root string) error { 67func addTree(w *fsnotify.Watcher, root string) error {
internal/library/watch_test.go +14
@@ -6,6 +6,8 @@ import (
6 "path/filepath" 6 "path/filepath"
7 "testing" 7 "testing"
8 "time" 8 "time"
9
10 "github.com/fsnotify/fsnotify"
9) 11)
10 12
11func expectCall(t *testing.T, ch <-chan struct{}, what string) { 13func expectCall(t *testing.T, ch <-chan struct{}, what string) {
@@ -43,3 +45,15 @@ func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
43 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644) 45 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
44 expectCall(t, calls, "write in new album") 46 expectCall(t, calls, "write in new album")
45} 47}
48
49func TestWatchLoopOverflowTriggersRescan(t *testing.T) {
50 ctx, cancel := context.WithCancel(context.Background())
51 defer cancel()
52 events := make(chan fsnotify.Event)
53 errs := make(chan error)
54 calls := make(chan struct{}, 1)
55 go watchLoop(ctx, events, errs, func(string) error { return nil }, 50*time.Millisecond, func() { calls <- struct{}{} })
56
57 errs <- fsnotify.ErrEventOverflow
58 expectCall(t, calls, "event overflow")
59}