1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "sort"
9 "time"
10 "unsafe"
11
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerowrap"
14 "golang.org/x/sys/unix"
15)
16
17var directoryPollInterval = 2 * time.Second
18
19func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
20 f, err := os.Open(path)
21 if err != nil {
22 return ports.Config{}, nil, nil, err
23 }
24 defer f.Close()
25 return parse(f)
26}
27
28// loadRaw returns the effective keys of the file at startup; missing means none.
29func loadRaw(path string) map[string]string {
30 _, raw, _, err := loadFile(path)
31 if err != nil {
32 return map[string]string{}
33 }
34 return raw
35}
36
37// diff lists keys added, removed or changed, sorted.
38func diff(old, cur map[string]string) []string {
39 var keys []string
40 for k, v := range cur {
41 if o, ok := old[k]; !ok || o != v {
42 keys = append(keys, k)
43 }
44 }
45 for k := range old {
46 if _, ok := cur[k]; !ok {
47 keys = append(keys, k)
48 }
49 }
50 sort.Strings(keys)
51 return keys
52}
53
54// Watch observes the parent directory so atomic file replacements are detected.
55// If the directory is absent, it retries every two seconds until it appears.
56func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
57 dir, name := filepath.Dir(path), filepath.Base(path)
58 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
59 if err != nil {
60 return err
61 }
62 defer unix.Close(fd)
63 const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
64 wd := -1
65 var pending time.Time
66 last := loadRaw(path)
67 buf := make([]byte, 4096)
68 for {
69 if ctx.Err() != nil {
70 return nil
71 }
72 if wd < 0 {
73 wd, err = unix.InotifyAddWatch(fd, dir, mask)
74 if errors.Is(err, unix.ENOENT) {
75 select {
76 case <-ctx.Done():
77 return nil
78 case <-time.After(directoryPollInterval):
79 continue
80 }
81 }
82 if err != nil {
83 return err
84 }
85 }
86 timeout := 200
87 if !pending.IsZero() {
88 remaining := time.Until(pending)
89 if remaining <= 0 {
90 pending = time.Time{}
91 cfg, raw, warnings, loadErr := loadFile(path)
92 switch {
93 case os.IsNotExist(loadErr):
94 log.Warn().Msg("config file removed; keeping current config")
95 case loadErr != nil:
96 log.Warn().Err(loadErr).Msg("config reload failed")
97 default:
98 for _, w := range warnings {
99 log.Warn().Int("line", w.Line).Msg(w.Msg)
100 }
101 changed := diff(last, raw)
102 last = raw
103 if len(changed) == 0 {
104 log.Debug().Msg("config unchanged")
105 continue
106 }
107 select {
108 case <-ctx.Done():
109 return nil
110 case out <- ports.ConfigChanged{Config: cfg}:
111 }
112 log.Info().Strs("changed", changed).Msg("config reloaded")
113 }
114 continue
115 }
116 if remaining < 200*time.Millisecond {
117 timeout = int(remaining.Milliseconds()) + 1
118 }
119 }
120 fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
121 _, err = unix.Poll(fds, timeout)
122 if err == unix.EINTR {
123 continue
124 }
125 if err != nil {
126 return err
127 }
128 if fds[0].Revents&unix.POLLIN == 0 {
129 continue
130 }
131 for {
132 n, readErr := unix.Read(fd, buf)
133 if readErr == unix.EAGAIN {
134 break
135 }
136 if readErr == unix.EINTR {
137 continue
138 }
139 if readErr != nil {
140 return readErr
141 }
142 if n == 0 {
143 break
144 }
145 for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
146 event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
147 size := unix.SizeofInotifyEvent + int(event.Len)
148 if offset+size > n {
149 break
150 }
151 if event.Mask&unix.IN_IGNORED != 0 {
152 wd = -1
153 }
154 if event.Len > 0 {
155 file := buf[offset+unix.SizeofInotifyEvent : offset+size]
156 for len(file) > 0 && file[len(file)-1] == 0 {
157 file = file[:len(file)-1]
158 }
159 if string(file) == name && event.Mask&mask != 0 {
160 pending = time.Now().Add(100 * time.Millisecond)
161 }
162 }
163 offset += size
164 }
165 }
166 }
167}