1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "slices"
9 "sort"
10 "time"
11 "unsafe"
12
13 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/zerowrap"
15 "golang.org/x/sys/unix"
16)
17
18var directoryPollInterval = 2 * time.Second
19
20func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
21 f, err := os.Open(path)
22 if err != nil {
23 return ports.Config{}, nil, nil, err
24 }
25 defer f.Close()
26 return parse(f)
27}
28
29// loadRaw returns the effective keys of the file at startup; missing means none.
30func loadRaw(path string) map[string]string {
31 _, raw, _, err := loadFile(path)
32 if err != nil {
33 return map[string]string{}
34 }
35 return raw
36}
37
38// diff lists keys added, removed or changed, sorted.
39func diff(old, cur map[string]string) []string {
40 var keys []string
41 for k, v := range cur {
42 if o, ok := old[k]; !ok || o != v {
43 keys = append(keys, k)
44 }
45 }
46 for k := range old {
47 if _, ok := cur[k]; !ok {
48 keys = append(keys, k)
49 }
50 }
51 sort.Strings(keys)
52 return keys
53}
54
55// watchedFiles are the files whose changes reload the config: path, then
56// each symlink it goes through (dotfiles) down to the final file, which may
57// not exist yet. Symlinked parent directories are followed by inotify
58// itself; retargeting one is not detected.
59func watchedFiles(path string) []string {
60 files := []string{filepath.Clean(path)}
61 for len(files) <= 40 { // the kernel's symlink limit
62 cur := files[len(files)-1]
63 target, err := os.Readlink(cur)
64 if err != nil {
65 break
66 }
67 if !filepath.IsAbs(target) {
68 target = filepath.Join(filepath.Dir(cur), target)
69 }
70 target = filepath.Clean(target)
71 if slices.Contains(files, target) {
72 break // a loop: the config is unreadable anyway
73 }
74 files = append(files, target)
75 }
76 return files
77}
78
79// Watch observes the parent directory so atomic file replacements are detected.
80// A symlinked config is also watched at its target, and a retargeted link
81// moves that watch. If the directory is absent, it retries every two seconds
82// until it appears.
83func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
84 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
85 if err != nil {
86 return err
87 }
88 defer unix.Close(fd)
89 // IN_MODIFY restarts the debounce on every write, the truncation of an
90 // in-place save included: the file is never read half written.
91 const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
92 // names maps each watch to the file names that matter in its directory.
93 names := map[int32]map[string]bool{}
94 var files []string
95 rewatch := true
96 // retry sets the watches up again while a directory is missing.
97 var retry time.Time
98 var pending time.Time
99 last := loadRaw(path)
100 buf := make([]byte, 4096)
101 for {
102 if ctx.Err() != nil {
103 return nil
104 }
105 if !retry.IsZero() && !time.Now().Before(retry) {
106 rewatch = true
107 }
108 if rewatch {
109 rewatch, retry = false, time.Time{}
110 for wd := range names {
111 _, _ = unix.InotifyRmWatch(fd, uint32(wd))
112 }
113 clear(names)
114 files = watchedFiles(path)
115 for _, file := range files {
116 wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
117 if errors.Is(err, unix.ENOENT) {
118 retry = time.Now().Add(directoryPollInterval)
119 continue
120 }
121 if err != nil {
122 return err
123 }
124 if names[int32(wd)] == nil {
125 names[int32(wd)] = map[string]bool{}
126 }
127 names[int32(wd)][filepath.Base(file)] = true
128 }
129 }
130 timeout := 200
131 if !retry.IsZero() {
132 timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
133 }
134 if !pending.IsZero() {
135 remaining := time.Until(pending)
136 if remaining <= 0 {
137 pending = time.Time{}
138 cfg, raw, warnings, loadErr := loadFile(path)
139 switch {
140 case os.IsNotExist(loadErr):
141 log.Warn().Msg("config file removed; keeping current config")
142 case loadErr != nil:
143 log.Warn().Err(loadErr).Msg("config reload failed")
144 default:
145 for _, w := range warnings {
146 log.Warn().Int("line", w.Line).Msg(w.Msg)
147 }
148 changed := diff(last, raw)
149 last = raw
150 if len(changed) == 0 {
151 log.Debug().Msg("config unchanged")
152 continue
153 }
154 select {
155 case <-ctx.Done():
156 return nil
157 case out <- ports.ConfigChanged{Config: cfg}:
158 }
159 log.Info().Strs("changed", changed).Msg("config reloaded")
160 }
161 continue
162 }
163 timeout = min(timeout, int(remaining.Milliseconds())+1)
164 }
165 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
166 _, err = unix.Poll(fds, timeout)
167 if err == unix.EINTR {
168 continue
169 }
170 if err != nil {
171 return err
172 }
173 if fds[0].Revents&unix.POLLIN == 0 {
174 continue
175 }
176 for {
177 n, readErr := unix.Read(fd, buf)
178 if readErr == unix.EAGAIN {
179 break
180 }
181 if readErr == unix.EINTR {
182 continue
183 }
184 if readErr != nil {
185 return readErr
186 }
187 if n == 0 {
188 break
189 }
190 for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
191 event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
192 size := unix.SizeofInotifyEvent + int(event.Len)
193 if offset+size > n {
194 break
195 }
196 // A removed watch of ours (its directory is gone) is set up
197 // again; removals we asked for are no longer in names.
198 if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil {
199 rewatch = true
200 }
201 if event.Len > 0 {
202 file := buf[offset+unix.SizeofInotifyEvent : offset+size]
203 for len(file) > 0 && file[len(file)-1] == 0 {
204 file = file[:len(file)-1]
205 }
206 if names[event.Wd][string(file)] && event.Mask&mask != 0 {
207 pending = time.Now().Add(100 * time.Millisecond)
208 // The link may point elsewhere now.
209 if !slices.Equal(files, watchedFiles(path)) {
210 rewatch = true
211 }
212 }
213 }
214 offset += size
215 }
216 }
217 }
218}