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