1// Package fswatch reports changes to one file with inotify.
2//
3// It watches the parent directory, so a file that is replaced by a rename (the
4// way editors and NeferWL write) is noticed, and it follows symlinks, so a
5// dotfile-managed file is noticed at its target.
6package fswatch
7
8import (
9 "context"
10 "errors"
11 "os"
12 "path/filepath"
13 "slices"
14 "time"
15 "unsafe"
16
17 "golang.org/x/sys/unix"
18)
19
20const (
21 // retryEvery is how often a missing directory is looked for again.
22 retryEvery = 2 * time.Second
23 // pollEvery bounds how long a wait lasts, so ctx is noticed.
24 pollEvery = 200 // ms
25 // mask is every way a file can be written, replaced or removed. IN_MODIFY
26 // restarts the debounce on each write, so a truncating in-place save is
27 // never read half written.
28 mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
29)
30
31// files lists path and every symlink target it goes through.
32func files(path string) []string {
33 out := []string{filepath.Clean(path)}
34 for len(out) <= 40 { // the kernel's symlink limit
35 cur := out[len(out)-1]
36 target, err := os.Readlink(cur)
37 if err != nil {
38 break
39 }
40 if !filepath.IsAbs(target) {
41 target = filepath.Join(filepath.Dir(cur), target)
42 }
43 target = filepath.Clean(target)
44 if slices.Contains(out, target) {
45 break
46 }
47 out = append(out, target)
48 }
49 return out
50}
51
52// Watch sends on out after path changed and stayed quiet for debounce. A
53// debounce of 0 reports each change at once. The send never blocks: if out is
54// full, the pending signal already says "something changed", which is all a
55// reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends.
56func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error {
57 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
58 if err != nil {
59 return err
60 }
61 defer unix.Close(fd)
62
63 names := map[int32]map[string]bool{} // watch -> file names that matter in its directory
64 var watched []string
65 var retry, pending time.Time
66 rewatch := true
67 buf := make([]byte, 4096)
68 fds := make([]unix.PollFd, 1)
69
70 for ctx.Err() == nil {
71 if !retry.IsZero() && !time.Now().Before(retry) {
72 rewatch = true
73 }
74 if rewatch {
75 rewatch, retry = false, time.Time{}
76 for wd := range names {
77 _, _ = unix.InotifyRmWatch(fd, uint32(wd))
78 }
79 clear(names)
80 watched = files(path)
81 for _, file := range watched {
82 wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
83 if errors.Is(err, unix.ENOENT) {
84 retry = time.Now().Add(retryEvery)
85 continue
86 }
87 if err != nil {
88 return err
89 }
90 if names[int32(wd)] == nil {
91 names[int32(wd)] = map[string]bool{}
92 }
93 names[int32(wd)][filepath.Base(file)] = true
94 }
95 }
96
97 timeout := pollEvery
98 if !retry.IsZero() {
99 timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
100 }
101 if !pending.IsZero() {
102 remaining := time.Until(pending)
103 if remaining <= 0 {
104 pending = time.Time{}
105 select {
106 case out <- struct{}{}:
107 default:
108 }
109 continue
110 }
111 timeout = min(timeout, int(remaining.Milliseconds())+1)
112 }
113
114 fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN}
115 if _, err = unix.Poll(fds, timeout); err != nil {
116 if err == unix.EINTR {
117 continue
118 }
119 return err
120 }
121 if fds[0].Revents&unix.POLLIN == 0 {
122 continue
123 }
124 for {
125 n, err := unix.Read(fd, buf)
126 if err == unix.EAGAIN || n == 0 {
127 break
128 }
129 if err == unix.EINTR {
130 continue
131 }
132 if err != nil {
133 return err
134 }
135 for off := 0; off+unix.SizeofInotifyEvent <= n; {
136 ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
137 size := unix.SizeofInotifyEvent + int(ev.Len)
138 if off+size > n {
139 break
140 }
141 // One of our watches vanished (its directory was removed).
142 if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil {
143 rewatch = true
144 }
145 if ev.Len > 0 {
146 name := buf[off+unix.SizeofInotifyEvent : off+size]
147 for len(name) > 0 && name[len(name)-1] == 0 {
148 name = name[:len(name)-1]
149 }
150 if names[ev.Wd][string(name)] && ev.Mask&mask != 0 {
151 pending = time.Now().Add(debounce)
152 if !slices.Equal(watched, files(path)) {
153 rewatch = true // a symlink now points elsewhere
154 }
155 }
156 }
157 off += size
158 }
159 }
160 }
161 return nil
162}