1package kvconf
2
3import (
4 "context"
5 "io/fs"
6 "maps"
7 "os"
8 "path/filepath"
9 "runtime"
10 "slices"
11 "strings"
12 "time"
13)
14
15// DefaultDebounce is the quiet time Watch waits for after a change.
16const DefaultDebounce = 100 * time.Millisecond
17
18// retryMissing is how often a missing directory is looked for again, in
19// case its creation was not seen.
20const retryMissing = 2 * time.Second
21
22// WatchOptions controls Watch.
23type WatchOptions struct {
24 // Debounce is the quiet time after a change before it is reported:
25 // every write restarts it, so a file is never read half written. 0
26 // means DefaultDebounce; a negative value reports each change at once.
27 Debounce time.Duration
28 // Dir watches every file directly inside the path, a directory, instead
29 // of the path itself. Hidden files (".swp") and backups ("~") are
30 // ignored and subdirectories are not watched.
31 Dir bool
32 // Poll, when positive, checks the files at this interval instead of
33 // using the native OS notifications. Native watching also falls back to
34 // polling every second where it is not available.
35 Poll time.Duration
36 // Ready, when not nil, is closed once the watches are installed: a
37 // change after that point is reported.
38 Ready chan<- struct{}
39}
40
41func (o *WatchOptions) debounce() time.Duration {
42 switch {
43 case o.Debounce == 0:
44 return DefaultDebounce
45 case o.Debounce < 0:
46 return 0
47 }
48 return o.Debounce
49}
50
51// Watch reports on out each time the file at path changes, is created,
52// replaced or removed, once it has been quiet for the debounce time. It
53// watches the parent directory, so editors that save by renaming a new file
54// over the old one are seen. It follows symlinks and watches each link of
55// the chain, so a dotfile-managed config is seen at its target and a
56// retargeted link is followed. A missing directory is waited for.
57//
58// The send never blocks: give out a buffer of 1, and a pending value already
59// means "changed". Watch returns nil when ctx ends, or an error when the OS
60// refuses to watch.
61func Watch(ctx context.Context, path string, opts WatchOptions, out chan<- struct{}) error {
62 path, err := filepath.Abs(path)
63 if err != nil {
64 return err
65 }
66 var b backend
67 if opts.Poll > 0 {
68 b = newPoller(opts.Poll)
69 } else if b, err = newNative(); err != nil {
70 return err
71 } else if b == nil {
72 b = newPoller(time.Second)
73 }
74 defer b.close() //nolint:errcheck // nothing to report once watching ends
75 stop := context.AfterFunc(ctx, b.wake)
76 defer stop()
77
78 w := &watcher{path: path, dir: opts.Dir, stamps: map[string]stamp{}}
79 missing, err := w.rewatch(b)
80 if err != nil {
81 return err
82 }
83 if opts.Ready != nil {
84 close(opts.Ready)
85 }
86 debounce := opts.debounce()
87 var pending, retry time.Time
88 if missing {
89 retry = time.Now().Add(retryMissing)
90 }
91 var evs []event
92 for ctx.Err() == nil {
93 timeout := time.Duration(-1)
94 for _, t := range [...]time.Time{pending, retry} {
95 if !t.IsZero() {
96 if d := max(time.Until(t), 0); timeout < 0 || d < timeout {
97 timeout = d
98 }
99 }
100 }
101 if evs, err = b.wait(timeout, evs[:0]); err != nil {
102 return err
103 }
104 relevant, rewatch, recheck := w.classify(evs)
105 now := time.Now()
106 if !retry.IsZero() && !now.Before(retry) {
107 rewatch, retry = true, time.Time{}
108 }
109 if recheck && retry.IsZero() {
110 // A removed directory may linger, unreported, until its last
111 // handle closes (Windows): look once more later.
112 retry = now.Add(retryMissing)
113 }
114 if rewatch {
115 changed := false
116 if missing, changed, err = w.rewatchChanged(b); err != nil {
117 return err
118 }
119 relevant = relevant || changed
120 if missing && retry.IsZero() {
121 retry = now.Add(retryMissing)
122 }
123 }
124 if relevant {
125 pending = now.Add(debounce)
126 }
127 if !pending.IsZero() && !now.Before(pending) {
128 pending = time.Time{}
129 // Record what is reported, so a later rewatch does not report
130 // the same change again.
131 for _, t := range w.targets {
132 w.stampsChanged(t)
133 }
134 select {
135 case out <- struct{}{}:
136 default:
137 }
138 }
139 }
140 return nil
141}
142
143// backend is the native notification API of one OS.
144type backend interface {
145 // set replaces the watched directories with targets. It reports
146 // whether a directory vanished before it could be watched.
147 set(targets []*target) (missing bool, err error)
148 // wait appends events to evs. It returns after events arrive, after
149 // timeout (forever when negative), or after wake.
150 wait(timeout time.Duration, evs []event) ([]event, error)
151 // wake makes the current or next wait return. Safe for concurrent use.
152 wake()
153 close() error
154}
155
156// target is one watched directory and the names that matter in it.
157type target struct {
158 dir string
159 // names are files whose changes are reported.
160 names []string
161 // all reports every visible file (Dir mode).
162 all bool
163 // next is the missing child on the way to the wanted directory: its
164 // creation means the watches must move down.
165 next string
166 // paths are dir joined with names and next, computed once.
167 paths []string
168 // moved lists watched child directories whose rename must move the
169 // watches (Windows does not report a watched directory's own rename).
170 moved []string
171}
172
173// stampPaths lists the full paths whose stamps matter.
174func (t *target) stampPaths() []string {
175 if t.paths == nil {
176 for _, n := range t.names {
177 t.paths = append(t.paths, filepath.Join(t.dir, n))
178 }
179 if t.next != "" {
180 t.paths = append(t.paths, filepath.Join(t.dir, t.next))
181 }
182 }
183 return t.paths
184}
185
186func (t *target) has(name string) bool {
187 return slices.Contains(t.names, name) || t.all && visible(name)
188}
189
190// event is a change in a watched directory. An empty name means "something
191// changed in dir" (kqueue, polling, overflow); an empty dir means anything
192// may have changed. lost means the watch itself vanished.
193type event struct {
194 dir, name string
195 lost bool
196 // moved: the name was created, removed or renamed, so a link may now
197 // point elsewhere. A plain write leaves links alone.
198 moved bool
199}
200
201func visible(name string) bool {
202 return name != "" && name[0] != '.' && name[len(name)-1] != '~'
203}
204
205type watcher struct {
206 path string
207 dir bool
208 targets []*target
209 chain []string
210 stamps map[string]stamp
211}
212
213// chain lists the paths whose change can change what path names: path
214// itself, every symlink met while resolving it, in the file name or in a
215// parent directory (GNU stow links whole directories), and the final file.
216// The resolved file is last.
217func chain(path string) []string {
218 path = filepath.Clean(path)
219 out := []string{path}
220 resolved, links := path, 0
221 for {
222 next, link, ok := resolveOnce(resolved)
223 if !ok {
224 break
225 }
226 if links++; links > 40 { // the kernel's symlink limit
227 break
228 }
229 if !slices.Contains(out, link) {
230 out = append(out, link)
231 }
232 if slices.Contains(out, next) && next != path {
233 break // a loop: the file is unreadable anyway
234 }
235 resolved = next
236 }
237 if resolved != path {
238 out = append(slices.DeleteFunc(out, func(p string) bool { return p == resolved }), resolved)
239 }
240 return out
241}
242
243// resolveOnce replaces the first symlink in path, from the root down, by its
244// target. link is the symlink's own path.
245func resolveOnce(path string) (next, link string, ok bool) {
246 vol := filepath.VolumeName(path)
247 rest := path[len(vol):]
248 cur := vol
249 for rest != "" {
250 sep := strings.IndexByte(rest, filepath.Separator)
251 var part string
252 if sep < 0 {
253 part, rest = rest, ""
254 } else {
255 part, rest = rest[:sep], rest[sep+1:]
256 }
257 if part == "" {
258 cur += string(filepath.Separator)
259 continue
260 }
261 cur = filepath.Join(cur, part)
262 target, err := os.Readlink(cur)
263 if err != nil {
264 continue // not a link, or missing: later parts cannot resolve
265 }
266 if !filepath.IsAbs(target) {
267 target = filepath.Join(filepath.Dir(cur), target)
268 }
269 return filepath.Join(target, rest), cur, true
270 }
271 return path, "", false
272}
273
274func isDir(path string) bool {
275 info, err := os.Stat(path)
276 return err == nil && info.IsDir()
277}
278
279// plan computes the targets for the current state of the file system.
280func (w *watcher) plan() (targets []*target, files []string) {
281 byDir := map[string]*target{}
282 get := func(dir string) *target {
283 t := byDir[dir]
284 if t == nil {
285 t = &target{dir: dir}
286 byDir[dir] = t
287 targets = append(targets, t)
288 }
289 return t
290 }
291 add := func(dir, name string, all bool) {
292 if !isDir(dir) {
293 // Watch the closest existing ancestor for the next missing child.
294 child := dir
295 for parent := filepath.Dir(child); ; parent = filepath.Dir(child) {
296 if parent == child {
297 return // nothing exists, not even the root
298 }
299 if isDir(parent) {
300 get(parent).next = normName(filepath.Base(child))
301 return
302 }
303 child = parent
304 }
305 }
306 t := get(dir)
307 t.all = t.all || all
308 if name != "" && !slices.Contains(t.names, name) {
309 t.names = append(t.names, name)
310 }
311 }
312 if w.dir {
313 files = []string{w.path}
314 add(w.path, "", true)
315 } else {
316 files = chain(w.path)
317 for _, f := range files {
318 add(filepath.Dir(f), normName(filepath.Base(f)), false)
319 }
320 }
321 if runtime.GOOS == "windows" {
322 for _, t := range slices.Clone(targets) {
323 if parent := filepath.Dir(t.dir); parent != t.dir && isDir(parent) {
324 p := get(parent)
325 p.moved = append(p.moved, normName(filepath.Base(t.dir)))
326 }
327 }
328 }
329 return targets, files
330}
331
332func (w *watcher) rewatch(b backend) (missing bool, err error) {
333 missing, _, err = w.rewatchChanged(b)
334 return missing, err
335}
336
337// rewatchChanged installs the watches again and reports whether the watched
338// files changed meanwhile: a link was retargeted, a missing directory
339// appeared, or a file changed while it was not watched.
340func (w *watcher) rewatchChanged(b backend) (missing, changed bool, err error) {
341 targets, files := w.plan()
342 changed = w.chain != nil && !slices.Equal(files, w.chain) || !sameTargets(targets, w.targets)
343 w.targets, w.chain = targets, files
344 missing, err = b.set(targets)
345 for _, t := range targets {
346 missing = missing || t.next != ""
347 }
348 old := w.stamps
349 w.stamps = make(map[string]stamp, len(old))
350 for _, t := range targets {
351 w.stampsChanged(t)
352 }
353 changed = changed || !maps.Equal(old, w.stamps)
354 return missing, changed, err
355}
356
357func sameTargets(a, b []*target) bool {
358 return slices.EqualFunc(a, b, func(x, y *target) bool {
359 return x.dir == y.dir && x.all == y.all && x.next == y.next && slices.Equal(x.names, y.names) &&
360 slices.Equal(x.moved, y.moved)
361 })
362}
363
364// classify reports whether evs touch a watched file, whether the watches
365// must be installed again, and whether to look again later because a
366// watch was lost.
367func (w *watcher) classify(evs []event) (relevant, rewatch, recheck bool) {
368 relinked := false
369 for _, ev := range evs {
370 if ev.lost || ev.dir == "" {
371 relevant, rewatch, recheck = true, true, true
372 continue
373 }
374 t := w.find(ev.dir)
375 if t == nil {
376 continue
377 }
378 if ev.name == "" {
379 if w.stampsChanged(t) {
380 // kqueue watches files by descriptor: a replaced file needs a new one.
381 relevant, rewatch = true, true
382 }
383 continue
384 }
385 name := normName(ev.name)
386 if t.next != "" && name == t.next || ev.moved && slices.Contains(t.moved, name) {
387 relevant, rewatch = true, true
388 }
389 if ev.moved && !rewatch && !isDir(t.dir) {
390 // Some systems (Windows, network file systems) do not report a
391 // watched directory that was removed: look again later.
392 relevant, rewatch, recheck = true, true, true
393 continue
394 }
395 if t.has(name) {
396 if t.all && ev.moved && isDir(filepath.Join(t.dir, name)) {
397 continue // Dir mode ignores subdirectories
398 }
399 relevant = true
400 relinked = relinked || ev.moved
401 // Windows reports a removed directory only once its last handle,
402 // ours, closes: after a removal inside it, look again later. The
403 // stamps keep that second look from reporting the change twice.
404 recheck = recheck || ev.moved && runtime.GOOS == "windows"
405 }
406 }
407 if relinked && !rewatch && !w.dir && !slices.Equal(chain(w.path), w.chain) {
408 rewatch = true // a link points elsewhere now
409 }
410 return relevant, rewatch, recheck
411}
412
413func (w *watcher) find(dir string) *target {
414 for _, t := range w.targets {
415 if t.dir == dir {
416 return t
417 }
418 }
419 return nil
420}
421
422// stamp identifies a version of a file without reading it.
423type stamp struct {
424 size int64
425 mtime int64
426 mode fs.FileMode
427 id uint64
428}
429
430func stampOf(info fs.FileInfo) stamp {
431 return stamp{size: info.Size(), mtime: info.ModTime().UnixNano(), mode: info.Mode(), id: fileID(info)}
432}
433
434// stampsChanged refreshes the stamps of the files that matter in t and
435// reports whether any was created, changed or removed.
436func (w *watcher) stampsChanged(t *target) bool {
437 changed := false
438 check := func(key string, info fs.FileInfo) {
439 old, had := w.stamps[key]
440 if info == nil {
441 if had {
442 delete(w.stamps, key)
443 changed = true
444 }
445 return
446 }
447 if s := stampOf(info); !had || old != s {
448 w.stamps[key] = s
449 changed = true
450 }
451 }
452 paths := t.stampPaths()
453 for _, p := range paths {
454 info, err := os.Lstat(p)
455 if err != nil {
456 info = nil
457 }
458 check(p, info)
459 }
460 if t.all {
461 entries, _ := os.ReadDir(t.dir)
462 live := map[string]bool{}
463 for _, e := range entries {
464 if !visible(e.Name()) || e.IsDir() {
465 continue
466 }
467 info, err := e.Info()
468 if err != nil {
469 continue
470 }
471 key := filepath.Join(t.dir, e.Name())
472 live[key] = true
473 check(key, info)
474 }
475 for key := range w.stamps {
476 if filepath.Dir(key) == t.dir && !live[key] && !slices.Contains(paths, key) {
477 delete(w.stamps, key)
478 changed = true
479 }
480 }
481 }
482 return changed
483}
484
485// normName folds the case of names on Windows, whose file names ignore it.
486func normName(name string) string {
487 if runtime.GOOS == "windows" {
488 return strings.ToLower(name)
489 }
490 return name
491}