1package config
2
3import (
4 "context"
5 "errors"
6 "github.com/bnema/kvconf"
7 "github.com/bnema/neferafk/internal/ports"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12)
13
14func recv[T any](t *testing.T, ch <-chan T) T {
15 t.Helper()
16 select {
17 case v := <-ch:
18 return v
19 case <-time.After(2 * time.Second):
20 t.Fatal("watch timeout")
21 var z T
22 return z
23 }
24}
25func save(t *testing.T, path, text string) {
26 t.Helper()
27 tmp := path + ".new"
28 if err := os.WriteFile(tmp, []byte(text), 0600); err != nil {
29 t.Fatal(err)
30 }
31 if err := os.Rename(tmp, path); err != nil {
32 t.Fatal(err)
33 }
34}
35func TestWatchAtomicInvalidTransactionalAndSymlinkRetarget(t *testing.T) {
36 dir := t.TempDir()
37 targetDir := t.TempDir()
38 path := filepath.Join(dir, "config")
39 target := filepath.Join(targetDir, "target")
40 save(t, path, "lock.after=5m\n")
41 out := make(chan ports.ConfigChanged, 4)
42 errs := make(chan error, 4)
43 ready := make(chan struct{})
44 ctx, cancel := context.WithCancel(context.Background())
45 done := make(chan error, 1)
46 go func() { done <- Watch(ctx, path, out, errs, ready) }()
47 recv(t, ready)
48 if initial := recv(t, out); initial.Config.LockAfter != 5*time.Minute {
49 t.Fatal(initial)
50 }
51 save(t, path, "lock.after=2m\n")
52 if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
53 t.Fatal(got)
54 }
55 save(t, path, "lock.after=-1s\n")
56 recv(t, errs)
57 select {
58 case v := <-out:
59 t.Fatalf("invalid reload emitted %+v", v)
60 default:
61 }
62 save(t, target, "lock.after=3m\n")
63 if err := os.Symlink(target, path+".link"); err != nil {
64 t.Fatal(err)
65 }
66 if err := os.Rename(path+".link", path); err != nil {
67 t.Fatal(err)
68 }
69 if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
70 t.Fatal(got)
71 }
72 save(t, target, "lock.after=4m\n")
73 if got := recv(t, out); got.Config.LockAfter != 4*time.Minute {
74 t.Fatal(got)
75 }
76 cancel()
77 if err := recv(t, done); err != nil {
78 t.Fatal(err)
79 }
80}
81func TestWatchCancellationWhileDeliveryBlocked(t *testing.T) {
82 path := filepath.Join(t.TempDir(), "config")
83 save(t, path, "lock.after=5m\n")
84 out := make(chan ports.ConfigChanged)
85 ready := make(chan struct{})
86 ctx, cancel := context.WithCancel(context.Background())
87 done := make(chan error, 1)
88 go func() { done <- Watch(ctx, path, out, nil, ready) }()
89 recv(t, ready)
90 save(t, path, "lock.after=1m\n")
91 cancel()
92 if err := recv(t, done); err != nil {
93 t.Fatal(err)
94 }
95}
96
97func TestWatchMissingDirectoryStartupThenRemoveRecreate(t *testing.T) {
98 root := t.TempDir()
99 dir := filepath.Join(root, "missing", "nested")
100 path := filepath.Join(dir, "config")
101 out := make(chan ports.ConfigChanged, 4)
102 ready := make(chan struct{})
103 ctx, cancel := context.WithCancel(context.Background())
104 defer cancel()
105 done := make(chan error, 1)
106 go func() { done <- Watch(ctx, path, out, nil, ready) }()
107 recv(t, ready)
108 if err := os.MkdirAll(dir, 0700); err != nil {
109 t.Fatal(err)
110 }
111 save(t, path, "lock.after=1m\n")
112 if got := recv(t, out); got.Config.LockAfter != time.Minute {
113 t.Fatal(got)
114 }
115 // Rename the entire watched directory out of the path, then recreate it.
116 if err := os.Rename(dir, dir+"-old"); err != nil {
117 t.Fatal(err)
118 }
119 if err := os.MkdirAll(dir, 0700); err != nil {
120 t.Fatal(err)
121 }
122 save(t, path, "lock.after=2m\n")
123 if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
124 t.Fatal(got)
125 }
126 cancel()
127 if err := recv(t, done); err != nil {
128 t.Fatal(err)
129 }
130}
131
132func TestWatchRetargetToUnavailableThenReappearKeepsLastValid(t *testing.T) {
133 root := t.TempDir()
134 path := filepath.Join(root, "config")
135 save(t, path, "lock.after=1m\n")
136 out := make(chan ports.ConfigChanged, 4)
137 ready := make(chan struct{})
138 ctx, cancel := context.WithCancel(context.Background())
139 defer cancel()
140 done := make(chan error, 1)
141 go func() { done <- Watch(ctx, path, out, nil, ready) }()
142 recv(t, ready)
143 recv(t, out)
144 target := filepath.Join(t.TempDir(), "absent", "entry")
145 if err := os.Symlink(target, path+".link"); err != nil {
146 t.Fatal(err)
147 }
148 if err := os.Rename(path+".link", path); err != nil {
149 t.Fatal(err)
150 }
151 select {
152 case reset := <-out:
153 t.Fatalf("missing target reset last valid %+v", reset)
154 case err := <-done:
155 t.Fatalf("watch stopped on missing target %v", err)
156 case <-time.After(200 * time.Millisecond):
157 }
158 if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
159 t.Fatal(err)
160 }
161 save(t, target, "lock.after=3m\n")
162 if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
163 t.Fatal(got)
164 }
165 cancel()
166 if err := recv(t, done); err != nil {
167 t.Fatal(err)
168 }
169}
170
171func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testing.T) {
172 path := filepath.Join(t.TempDir(), "config")
173 save(t, path, "lock.after=1m\n")
174 old, err := Load(path)
175 if err != nil {
176 t.Fatal(err)
177 }
178 save(t, path, "lock.after=2m\n") // save after app's Load, before Watch
179 out := make(chan ports.ConfigChanged, 1)
180 ready := make(chan struct{})
181 ctx, cancel := context.WithCancel(context.Background())
182 done := make(chan error, 1)
183 go func() { done <- Watch(ctx, path, out, nil, ready) }()
184 recv(t, ready)
185 if got := recv(t, out); got.Config == old || got.Config.LockAfter != 2*time.Minute {
186 t.Fatal(got)
187 }
188 cancel()
189 if err := recv(t, done); err != nil {
190 t.Fatal(err)
191 }
192 ctx, cancel = context.WithCancel(context.Background())
193 done = make(chan error, 1)
194 ready = make(chan struct{})
195 go func() { done <- Watch(ctx, filepath.Join(t.TempDir(), "never", "config"), out, nil, ready) }()
196 recv(t, ready)
197 cancel()
198 if err := recv(t, done); err != nil {
199 t.Fatal(err)
200 }
201}
202
203// A watch the OS refuses must fall back to polling, never stop the daemon.
204func TestWatchFallsBackToPolling(t *testing.T) {
205 orig, origPoll := kvWatch, fallbackPoll
206 t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll })
207 fallbackPoll = 20 * time.Millisecond
208 for _, tc := range []struct {
209 name string
210 setup func(t *testing.T, dir string)
211 }{
212 {"native watch fails at startup", func(t *testing.T, _ string) {
213 kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
214 if opts.Poll == 0 {
215 return errors.New("no space left on device")
216 }
217 return orig(ctx, path, opts, out)
218 }
219 }},
220 {"native watch fails after install", func(t *testing.T, _ string) {
221 kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
222 if opts.Poll == 0 {
223 close(opts.Ready)
224 return errors.New("no space left on device")
225 }
226 return orig(ctx, path, opts, out)
227 }
228 }},
229 {"directory not readable", func(t *testing.T, dir string) {
230 if os.Geteuid() == 0 {
231 t.Skip("root ignores directory permissions")
232 }
233 kvWatch = orig
234 if err := os.Chmod(dir, 0300); err != nil {
235 t.Fatal(err)
236 }
237 t.Cleanup(func() { os.Chmod(dir, 0700) })
238 }},
239 } {
240 t.Run(tc.name, func(t *testing.T) {
241 dir := t.TempDir()
242 path := filepath.Join(dir, "config")
243 save(t, path, "lock.after=1m\n")
244 tc.setup(t, dir)
245 out := make(chan ports.ConfigChanged, 4)
246 errs := make(chan error, 4)
247 ready := make(chan struct{})
248 ctx, cancel := context.WithCancel(context.Background())
249 defer cancel()
250 done := make(chan error, 1)
251 go func() { done <- Watch(ctx, path, out, errs, ready) }()
252 recv(t, ready)
253 if err := recv(t, errs); err.Error() != "config watch failed; polling" {
254 t.Fatal(err)
255 }
256 if got := recv(t, out); got.Config.LockAfter != time.Minute {
257 t.Fatal(got)
258 }
259 save(t, path, "lock.after=2m\n")
260 if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
261 t.Fatal(got)
262 }
263 cancel()
264 if err := recv(t, done); err != nil {
265 t.Fatal(err)
266 }
267 })
268 }
269}