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 (
1111 "os/signal"
1212 "path/filepath"
1313 "runtime"
14 "strings"
15 "sync"
1416 "syscall"
1517 "time"
1618
@@ -27,6 +29,23 @@ func env(key, def string) string {
2729 return def
2830}
2931
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
3049func main() {
3150 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
3251 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
@@ -40,6 +59,9 @@ func main() {
4059 flag.Usage()
4160 os.Exit(2)
4261 }
62 if err := checkDirs(*photos, *cache); err != nil {
63 log.Fatal(err)
64 }
4365 if err := format.CheckTools(); err != nil {
4466 log.Fatal(err)
4567 }
@@ -51,7 +73,10 @@ func main() {
5173 }
5274 rend := render.New(*cache, runtime.NumCPU())
5375 store := &library.Store{}
76 var scanMu sync.Mutex
5477 rescan := func() error {
78 scanMu.Lock()
79 defer scanMu.Unlock()
5580 lib, err := library.Scan(*photos, reg, meta)
5681 if err != nil {
5782 return err
@@ -65,10 +90,6 @@ func main() {
6590 log.Printf("library: %d albums", len(lib.Albums))
6691 return nil
6792 }
68 if err := rescan(); err != nil {
69 log.Fatal(err)
70 }
71
7293 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
7394 defer stop()
7495 onChange := func() {
@@ -76,9 +97,13 @@ func main() {
7697 log.Printf("rescan: %v", err)
7798 }
7899 }
100 // Watch before the first scan so files added during it are not missed.
79101 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
80102 log.Fatal(err)
81103 }
104 if err := rescan(); err != nil {
105 log.Fatal(err)
106 }
82107
83108 opt := web.Options{Title: *title, ThemePath: *theme}
84109 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
22
33import (
44 "context"
5 "errors"
56 "log"
67 "os"
78 "path/filepath"
@@ -23,35 +24,44 @@ func Watch(ctx context.Context, root string, debounce time.Duration, onChange fu
2324 }
2425 go func() {
2526 defer w.Close()
26 timer := time.NewTimer(debounce)
27 timer.Stop()
28 for {
29 select {
30 case <-ctx.Done():
27 watchLoop(ctx, w.Events, w.Errors, w.Add, debounce, onChange)
28 }()
29 return nil
30}
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 {
3143 return
32 case ev, ok := <-w.Events:
33 if !ok {
34 return
35 }
36 if ev.Has(fsnotify.Create) {
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 }
44 }
45 if ev.Has(fsnotify.Create) {
46 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
47 if err := add(ev.Name); err != nil {
48 log.Printf("watch %s: %v", ev.Name, err)
4149 }
4250 }
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) {
4359 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()
5160 }
61 case <-timer.C:
62 onChange()
5263 }
53 }()
54 return nil
64 }
5565}
5666
5767func addTree(w *fsnotify.Watcher, root string) error {
internal/library/watch_test.go +14
@@ -6,6 +6,8 @@ import (
66 "path/filepath"
77 "testing"
88 "time"
9
10 "github.com/fsnotify/fsnotify"
911)
1012
1113func expectCall(t *testing.T, ch <-chan struct{}, what string) {
@@ -43,3 +45,15 @@ func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
4345 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
4446 expectCall(t, calls, "write in new album")
4547}
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}