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