1package config
2
3import (
4 "context"
5 "encoding/binary"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/bnema/neferafk/internal/ports"
13 "golang.org/x/sys/unix"
14)
15
16func watchedFiles(path string) []string {
17 files := []string{filepath.Clean(path)}
18 for len(files) <= 40 {
19 cur := files[len(files)-1]
20 target, err := os.Readlink(cur)
21 if err != nil {
22 break
23 }
24 if !filepath.IsAbs(target) {
25 target = filepath.Join(filepath.Dir(cur), target)
26 }
27 target = filepath.Clean(target)
28 duplicate := false
29 for _, p := range files {
30 if p == target {
31 duplicate = true
32 break
33 }
34 }
35 if duplicate {
36 break
37 }
38 files = append(files, target)
39 }
40 return files
41}
42
43// watchDirectory finds the nearest existing ancestor. The missing component's
44// creation wakes inotify; the two-second retry also covers lost/overflow events.
45func watchDirectory(file string) (directory, name string, missing bool) {
46 directory, name = filepath.Dir(file), filepath.Base(file)
47 for {
48 st, err := os.Stat(directory)
49 if err == nil && st.IsDir() {
50 return
51 }
52 missing = true
53 parent := filepath.Dir(directory)
54 if parent == directory {
55 return
56 }
57 name, directory = filepath.Base(directory), parent
58 }
59}
60
61// Watch retains last-valid state across missing files/directories and follows
62// config symlink targets. It always emits the first valid candidate, including
63// a candidate equal to defaults: this closes an app Load -> Watch startup gap.
64// Ready closes after initial watch installation, before candidate delivery.
65// No invalid/deleted candidate emits a defaults reset. Errors never echo file
66// content or paths, which may themselves contain sensitive source metadata.
67func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {
68 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
69 if err != nil {
70 return errors.New("initialize config watch")
71 }
72 defer unix.Close(fd)
73 const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF
74 names := map[int32]map[string]bool{}
75 retry := time.Time{}
76 rewatch := func() {
77 // Keep unchanged watches, avoiding IN_IGNORED from our own removals.
78 want := map[int32]map[string]bool{}
79 retry = time.Time{}
80 for _, file := range watchedFiles(path) {
81 dir, name, missing := watchDirectory(file)
82 wd, e := unix.InotifyAddWatch(fd, dir, mask)
83 if e != nil {
84 retry = time.Now().Add(2 * time.Second)
85 continue
86 }
87 if missing {
88 retry = time.Now().Add(2 * time.Second)
89 }
90 if want[int32(wd)] == nil {
91 want[int32(wd)] = map[string]bool{}
92 }
93 want[int32(wd)][name] = true
94 }
95 for wd := range names {
96 if want[wd] == nil {
97 _, _ = unix.InotifyRmWatch(fd, uint32(wd))
98 }
99 }
100 names = want
101 }
102 rewatch()
103 if ready != nil {
104 close(ready)
105 }
106 var last ports.Config
107 haveLast := false
108 publish := func() bool {
109 cfg, e := Load(path)
110 if e != nil {
111 if !os.IsNotExist(e) && failures != nil {
112 select {
113 case failures <- errors.New("config reload rejected"):
114 default:
115 }
116 }
117 return true
118 }
119 if haveLast && cfg == last {
120 return true
121 }
122 select {
123 case out <- ports.ConfigChanged{Config: cfg}:
124 last, haveLast = cfg, true
125 return true
126 case <-ctx.Done():
127 return false
128 }
129 }
130 if !publish() {
131 return nil
132 }
133 pending := time.Time{}
134 buf := make([]byte, 8192)
135 for ctx.Err() == nil {
136 now := time.Now()
137 if !retry.IsZero() && !now.Before(retry) {
138 rewatch()
139 pending = now
140 }
141 if !pending.IsZero() && !now.Before(pending) {
142 pending = time.Time{}
143 rewatch()
144 if !publish() {
145 return nil
146 }
147 }
148 timeout := 100 // cancellation bound, no periodic reads while watches are live
149 if !pending.IsZero() {
150 timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds())))
151 }
152 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
153 _, e := unix.Poll(fds, timeout)
154 if errors.Is(e, unix.EINTR) {
155 continue
156 }
157 if e != nil {
158 return errors.New("poll config watch")
159 }
160 if fds[0].Revents&unix.POLLIN == 0 {
161 continue
162 }
163 n, e := unix.Read(fd, buf)
164 if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) {
165 continue
166 }
167 if e != nil {
168 return errors.New("read config watch")
169 }
170 for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
171 b := buf[offset:]
172 wd := int32(binary.NativeEndian.Uint32(b))
173 flags := binary.NativeEndian.Uint32(b[4:])
174 length := int(binary.NativeEndian.Uint32(b[12:]))
175 size := unix.SizeofInotifyEvent + length
176 if size > n-offset {
177 break
178 }
179 if flags&unix.IN_Q_OVERFLOW != 0 {
180 pending = time.Now().Add(100 * time.Millisecond)
181 }
182 if names[wd] != nil {
183 if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 {
184 if flags&unix.IN_IGNORED == 0 {
185 _, _ = unix.InotifyRmWatch(fd, uint32(wd))
186 }
187 delete(names, wd)
188 pending = time.Now().Add(100 * time.Millisecond)
189 retry = time.Now().Add(2 * time.Second)
190 }
191 name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00")
192 if names[wd][name] {
193 pending = time.Now().Add(100 * time.Millisecond)
194 }
195 }
196 offset += size
197 }
198 }
199 return nil
200}