detached e5fd306

refactor(config): watch and save the config with kvconf

commit
e5fd3064b726697b1c4b190a0ec07951f457bf5c
author
brice · · 2026-10-10 02:59 UTC
parent
60af2d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +68 -368
M internal/adapters/config/config.go +16 -27
@@ -2,7 +2,6 @@
22 package config
33
44 import (
5- "bufio"
65 "errors"
76 "fmt"
87 "io"
@@ -17,6 +16,7 @@ import (
1716 "time"
1817 "unicode"
1918
19+ "github.com/bnema/kvconf"
2020 "github.com/bnema/neferwl/internal/ports"
2121 )
2222
@@ -333,21 +333,24 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333333 workspaces := map[string]int{}
334334 rules := map[string]*ruleEntry{}
335335 var ruleOrder []*ruleEntry
336- scanner := bufio.NewScanner(r)
337- for n := 1; scanner.Scan(); n++ {
338- line := strings.TrimSpace(scanner.Text())
339- if line == "" || strings.HasPrefix(line, "#") {
340- continue
341- }
336+ // kvconf owns the line syntax; the values stay raw (neferwl has no
337+ // quoting) and repeated keys are reported below with their own wording.
338+ data, err := io.ReadAll(r)
339+ if err != nil {
340+ return c, raw, nil, err
341+ }
342+ entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
343+ if err != nil {
344+ return c, raw, nil, err
345+ }
346+ for _, w := range lineWarnings {
347+ warnings = append(warnings, Warning{Line: w.Line, Msg: w.Msg})
348+ }
349+ for _, e := range entries {
350+ n, key, value := e.Line, e.Key, e.Value
342351 warn := func(format string, a ...any) {
343352 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
344353 }
345- key, value, ok := strings.Cut(line, "=")
346- key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
347- if !ok || key == "" {
348- warn("expected key = value")
349- continue
350- }
351354 if combo, ok := strings.CutPrefix(key, "bind."); ok {
352355 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
353356 warn("%s: write %sequal instead of %s=", key, combo, combo)
@@ -551,9 +554,6 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
551554 }
552555 override()
553556 }
554- if err := scanner.Err(); err != nil {
555- return c, raw, warnings, err
556- }
557557 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
558558 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
559559 var ruleWarnings []Warning
@@ -1185,17 +1185,6 @@ func splitList(v string) []string {
11851185 return list
11861186 }
11871187
1188-// stripComment drops a trailing ` # comment` from a value. A # that starts the
1189-// value is kept, so colors like #1e1e2e need no quotes.
1190-func stripComment(value string) string {
1191- for i := 1; i < len(value); i++ {
1192- if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
1193- return strings.TrimSpace(value[:i])
1194- }
1195- }
1196- return value
1197-}
1198-
11991188 func checkAction(v string) error {
12001189 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
12011190 if len(ports.SplitCommands(rest)) == 0 {
@@ -2,7 +2,6 @@
2 package config2 package config
3 3
4 import (4 import (
5- "bufio"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "io"7 "io"
@@ -17,6 +16,7 @@ import (
17 "time"16 "time"
18 "unicode"17 "unicode"
19 18
19+ "github.com/bnema/kvconf"
20 "github.com/bnema/neferwl/internal/ports"20 "github.com/bnema/neferwl/internal/ports"
21 )21 )
22 22
@@ -333,21 +333,24 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333 workspaces := map[string]int{}333 workspaces := map[string]int{}
334 rules := map[string]*ruleEntry{}334 rules := map[string]*ruleEntry{}
335 var ruleOrder []*ruleEntry335 var ruleOrder []*ruleEntry
336- scanner := bufio.NewScanner(r)336+ // kvconf owns the line syntax; the values stay raw (neferwl has no
337- for n := 1; scanner.Scan(); n++ {337+ // quoting) and repeated keys are reported below with their own wording.
338- line := strings.TrimSpace(scanner.Text())338+ data, err := io.ReadAll(r)
339- if line == "" || strings.HasPrefix(line, "#") {339+ if err != nil {
340- continue340+ return c, raw, nil, err
341- }341+ }
342+ entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
343+ if err != nil {
344+ return c, raw, nil, err
345+ }
346+ for _, w := range lineWarnings {
347+ warnings = append(warnings, Warning{Line: w.Line, Msg: w.Msg})
348+ }
349+ for _, e := range entries {
350+ n, key, value := e.Line, e.Key, e.Value
342 warn := func(format string, a ...any) {351 warn := func(format string, a ...any) {
343 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})352 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
344 }353 }
345- key, value, ok := strings.Cut(line, "=")
346- key, value = strings.TrimSpace(key), stripComment(strings.TrimSpace(value))
347- if !ok || key == "" {
348- warn("expected key = value")
349- continue
350- }
351 if combo, ok := strings.CutPrefix(key, "bind."); ok {354 if combo, ok := strings.CutPrefix(key, "bind."); ok {
352 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {355 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
353 warn("%s: write %sequal instead of %s=", key, combo, combo)356 warn("%s: write %sequal instead of %s=", key, combo, combo)
@@ -551,9 +554,6 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
551 }554 }
552 override()555 override()
553 }556 }
554- if err := scanner.Err(); err != nil {
555- return c, raw, warnings, err
556- }
557 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)557 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
558 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)558 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
559 var ruleWarnings []Warning559 var ruleWarnings []Warning
@@ -1185,17 +1185,6 @@ func splitList(v string) []string {
1185 return list1185 return list
1186 }1186 }
1187 1187
1188-// stripComment drops a trailing ` # comment` from a value. A # that starts the
1189-// value is kept, so colors like #1e1e2e need no quotes.
1190-func stripComment(value string) string {
1191- for i := 1; i < len(value); i++ {
1192- if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
1193- return strings.TrimSpace(value[:i])
1194- }
1195- }
1196- return value
1197-}
1198-
1199 func checkAction(v string) error {1188 func checkAction(v string) error {
1200 if rest, ok := strings.CutPrefix(v, "spawn "); ok {1189 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
1201 if len(ports.SplitCommands(rest)) == 0 {1190 if len(ports.SplitCommands(rest)) == 0 {
M internal/adapters/config/rules_test.go +5 -3
@@ -3,6 +3,8 @@ package config
33 import (
44 "strings"
55 "testing"
6+
7+ "github.com/bnema/kvconf"
68 )
79
810 func TestRulesInFileOrder(t *testing.T) {
@@ -101,13 +103,13 @@ func TestRuleReloadDiff(t *testing.T) {
101103 _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
102104 _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
103105 _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
104- if d := diff(a, b); len(d) == 0 {
106+ if d := kvconf.Changed(a, b); len(d) == 0 {
105107 t.Fatal("reordering rules is not a change")
106108 }
107- if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
109+ if d := kvconf.Changed(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
108110 t.Fatal(d)
109111 }
110- if d := diff(a, a); len(d) != 0 {
112+ if d := kvconf.Changed(a, a); len(d) != 0 {
111113 t.Fatal(d)
112114 }
113115 }
@@ -3,6 +3,8 @@ package config
3 import (3 import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/kvconf"
6 )8 )
7 9
8 func TestRulesInFileOrder(t *testing.T) {10 func TestRulesInFileOrder(t *testing.T) {
@@ -101,13 +103,13 @@ func TestRuleReloadDiff(t *testing.T) {
101 _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")103 _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n")
102 _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")104 _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n")
103 _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")105 _, c, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\nrule.b.floating = on\n")
104- if d := diff(a, b); len(d) == 0 {106+ if d := kvconf.Changed(a, b); len(d) == 0 {
105 t.Fatal("reordering rules is not a change")107 t.Fatal("reordering rules is not a change")
106 }108 }
107- if d := diff(a, c); len(d) != 1 || d[0] != "rule.b.floating" {109+ if d := kvconf.Changed(a, c); len(d) != 1 || d[0] != "rule.b.floating" {
108 t.Fatal(d)110 t.Fatal(d)
109 }111 }
110- if d := diff(a, a); len(d) != 0 {112+ if d := kvconf.Changed(a, a); len(d) != 0 {
111 t.Fatal(d)113 t.Fatal(d)
112 }114 }
113 }115 }
M internal/adapters/config/scalestore.go +5 -134
@@ -1,14 +1,11 @@
11 package config
22
33 import (
4- "bytes"
5- "errors"
64 "fmt"
75 "math"
8- "os"
9- "path/filepath"
106 "strconv"
11- "strings"
7+
8+ "github.com/bnema/kvconf"
129 )
1310
1411 // ScaleStore writes output.<name>.scale into the config file, preserving
@@ -17,110 +14,10 @@ type ScaleStore struct{ Path string }
1714
1815 // SaveOutputScale updates output.<name>.scale in the config file. The file is
1916 // replaced atomically; a symlinked config is written through to its target.
17+// A new config may hold commands: only its owner reads it (0600).
2018 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
21- path := s.Path
22- if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
23- // Replacing a dangling link would lose it: report it instead.
24- if path, err = filepath.EvalSymlinks(path); err != nil {
25- return err
26- }
27- }
28- data, err := os.ReadFile(path)
29- // A new config may hold commands: only its owner reads it.
30- mode := os.FileMode(0o600)
31- switch {
32- case errors.Is(err, os.ErrNotExist):
33- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
34- return err
35- }
36- case err != nil:
37- return err
38- default:
39- if info, err := os.Stat(path); err == nil {
40- mode = info.Mode().Perm()
41- }
42- }
43- return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
44-}
45-
46-// setKey replaces the last active assignment, or enables a matching commented
47-// assignment. New keys go beside the closest related key, then at the end.
48-func setKey(data []byte, key, value string) []byte {
49- lines := strings.SplitAfter(string(data), "\n")
50- commented := -1
51- for i, line := range lines {
52- body := strings.TrimSpace(line)
53- if !strings.HasPrefix(body, "#") {
54- continue
55- }
56- body = strings.TrimSpace(strings.TrimPrefix(body, "#"))
57- k, _, ok := strings.Cut(body, "=")
58- if ok && strings.TrimSpace(k) == key {
59- commented = i
60- }
61- }
62- for i := len(lines) - 1; i >= 0; i-- {
63- prefix, rest, ok := strings.Cut(lines[i], "=")
64- if !ok || strings.TrimSpace(prefix) != key {
65- continue
66- }
67- lines[i] = replaceValue(prefix+"=", rest, value)
68- return []byte(strings.Join(lines, ""))
69- }
70- if commented >= 0 {
71- line := lines[commented]
72- indent, body, _ := strings.Cut(line, "#")
73- prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74- lines[commented] = replaceValue(indent+prefix+"=", rest, value)
75- return []byte(strings.Join(lines, ""))
76- }
77- // Match the longest dotted parent first: output.DP-2.scale belongs beside
78- // output.DP-2.*, not merely beside any output.* key.
79- for parent := key; ; {
80- dot := strings.LastIndexByte(parent, '.')
81- if dot < 0 {
82- break
83- }
84- parent = parent[:dot]
85- index := -1
86- for i, line := range lines {
87- k, _, ok := strings.Cut(line, "=")
88- k = strings.TrimSpace(k)
89- if ok && (k == parent || strings.HasPrefix(k, parent+".")) {
90- index = i
91- }
92- }
93- if index < 0 {
94- continue
95- }
96- eol := "\n"
97- if strings.HasSuffix(lines[index], "\r\n") {
98- eol = "\r\n"
99- }
100- if !strings.HasSuffix(lines[index], "\n") {
101- lines[index] += eol
102- }
103- lines = append(lines, "")
104- copy(lines[index+2:], lines[index+1:])
105- lines[index+1] = key + " = " + value + eol
106- return []byte(strings.Join(lines, ""))
107- }
108- var b bytes.Buffer
109- b.Write(data)
110- if len(data) > 0 && data[len(data)-1] != '\n' {
111- b.WriteByte('\n')
112- }
113- fmt.Fprintf(&b, "%s = %s\n", key, value)
114- return b.Bytes()
115-}
116-
117-// replaceValue keeps indentation, spacing and trailing comments in an assignment.
118-func replaceValue(prefix, rest, value string) string {
119- plain := strings.TrimRight(rest, "\r\n")
120- eol := rest[len(plain):]
121- old := stripComment(strings.TrimSpace(plain))
122- start := strings.Index(plain, old)
123- return prefix + plain[:start] + value + plain[start+len(old):] + eol
19+ value := formatScale(scale)
20+ return kvconf.SetFile(s.Path, map[string]*string{"output." + output + ".scale": &value}, 0o600)
12421 }
12522
12623 // formatScale writes scales as the config expects them: a decimal when it is
@@ -148,29 +45,3 @@ func gcd(a, b int) int {
14845 }
14946 return a
15047 }
151-
152-// writeAtomic replaces path with data: readers and the watcher never see a
153-// half-written file.
154-func writeAtomic(path string, data []byte, mode os.FileMode) error {
155- tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
156- if err != nil {
157- return err
158- }
159- defer os.Remove(tmp.Name())
160- if _, err := tmp.Write(data); err != nil {
161- tmp.Close()
162- return err
163- }
164- if err := tmp.Chmod(mode); err != nil {
165- tmp.Close()
166- return err
167- }
168- if err := tmp.Sync(); err != nil {
169- tmp.Close()
170- return err
171- }
172- if err := tmp.Close(); err != nil {
173- return err
174- }
175- return os.Rename(tmp.Name(), path)
176-}
@@ -1,14 +1,11 @@
1 package config1 package config
2 2
3 import (3 import (
4- "bytes"
5- "errors"
6 "fmt"4 "fmt"
7 "math"5 "math"
8- "os"
9- "path/filepath"
10 "strconv"6 "strconv"
11- "strings"7+
8+ "github.com/bnema/kvconf"
12 )9 )
13 10
14 // ScaleStore writes output.<name>.scale into the config file, preserving11 // ScaleStore writes output.<name>.scale into the config file, preserving
@@ -17,110 +14,10 @@ type ScaleStore struct{ Path string }
17 14
18 // SaveOutputScale updates output.<name>.scale in the config file. The file is15 // SaveOutputScale updates output.<name>.scale in the config file. The file is
19 // replaced atomically; a symlinked config is written through to its target.16 // replaced atomically; a symlinked config is written through to its target.
17+// A new config may hold commands: only its owner reads it (0600).
20 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {18 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
21- path := s.Path19+ value := formatScale(scale)
22- if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {20+ return kvconf.SetFile(s.Path, map[string]*string{"output." + output + ".scale": &value}, 0o600)
23- // Replacing a dangling link would lose it: report it instead.
24- if path, err = filepath.EvalSymlinks(path); err != nil {
25- return err
26- }
27- }
28- data, err := os.ReadFile(path)
29- // A new config may hold commands: only its owner reads it.
30- mode := os.FileMode(0o600)
31- switch {
32- case errors.Is(err, os.ErrNotExist):
33- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
34- return err
35- }
36- case err != nil:
37- return err
38- default:
39- if info, err := os.Stat(path); err == nil {
40- mode = info.Mode().Perm()
41- }
42- }
43- return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
44-}
45-
46-// setKey replaces the last active assignment, or enables a matching commented
47-// assignment. New keys go beside the closest related key, then at the end.
48-func setKey(data []byte, key, value string) []byte {
49- lines := strings.SplitAfter(string(data), "\n")
50- commented := -1
51- for i, line := range lines {
52- body := strings.TrimSpace(line)
53- if !strings.HasPrefix(body, "#") {
54- continue
55- }
56- body = strings.TrimSpace(strings.TrimPrefix(body, "#"))
57- k, _, ok := strings.Cut(body, "=")
58- if ok && strings.TrimSpace(k) == key {
59- commented = i
60- }
61- }
62- for i := len(lines) - 1; i >= 0; i-- {
63- prefix, rest, ok := strings.Cut(lines[i], "=")
64- if !ok || strings.TrimSpace(prefix) != key {
65- continue
66- }
67- lines[i] = replaceValue(prefix+"=", rest, value)
68- return []byte(strings.Join(lines, ""))
69- }
70- if commented >= 0 {
71- line := lines[commented]
72- indent, body, _ := strings.Cut(line, "#")
73- prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74- lines[commented] = replaceValue(indent+prefix+"=", rest, value)
75- return []byte(strings.Join(lines, ""))
76- }
77- // Match the longest dotted parent first: output.DP-2.scale belongs beside
78- // output.DP-2.*, not merely beside any output.* key.
79- for parent := key; ; {
80- dot := strings.LastIndexByte(parent, '.')
81- if dot < 0 {
82- break
83- }
84- parent = parent[:dot]
85- index := -1
86- for i, line := range lines {
87- k, _, ok := strings.Cut(line, "=")
88- k = strings.TrimSpace(k)
89- if ok && (k == parent || strings.HasPrefix(k, parent+".")) {
90- index = i
91- }
92- }
93- if index < 0 {
94- continue
95- }
96- eol := "\n"
97- if strings.HasSuffix(lines[index], "\r\n") {
98- eol = "\r\n"
99- }
100- if !strings.HasSuffix(lines[index], "\n") {
101- lines[index] += eol
102- }
103- lines = append(lines, "")
104- copy(lines[index+2:], lines[index+1:])
105- lines[index+1] = key + " = " + value + eol
106- return []byte(strings.Join(lines, ""))
107- }
108- var b bytes.Buffer
109- b.Write(data)
110- if len(data) > 0 && data[len(data)-1] != '\n' {
111- b.WriteByte('\n')
112- }
113- fmt.Fprintf(&b, "%s = %s\n", key, value)
114- return b.Bytes()
115-}
116-
117-// replaceValue keeps indentation, spacing and trailing comments in an assignment.
118-func replaceValue(prefix, rest, value string) string {
119- plain := strings.TrimRight(rest, "\r\n")
120- eol := rest[len(plain):]
121- old := stripComment(strings.TrimSpace(plain))
122- start := strings.Index(plain, old)
123- return prefix + plain[:start] + value + plain[start+len(old):] + eol
124 }21 }
125 22
126 // formatScale writes scales as the config expects them: a decimal when it is23 // formatScale writes scales as the config expects them: a decimal when it is
@@ -148,29 +45,3 @@ func gcd(a, b int) int {
148 }45 }
149 return a46 return a
150 }47 }
151-
152-// writeAtomic replaces path with data: readers and the watcher never see a
153-// half-written file.
154-func writeAtomic(path string, data []byte, mode os.FileMode) error {
155- tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
156- if err != nil {
157- return err
158- }
159- defer os.Remove(tmp.Name())
160- if _, err := tmp.Write(data); err != nil {
161- tmp.Close()
162- return err
163- }
164- if err := tmp.Chmod(mode); err != nil {
165- tmp.Close()
166- return err
167- }
168- if err := tmp.Sync(); err != nil {
169- tmp.Close()
170- return err
171- }
172- if err := tmp.Close(); err != nil {
173- return err
174- }
175- return os.Rename(tmp.Name(), path)
176-}
M internal/adapters/config/watch.go +42 -176
@@ -2,20 +2,17 @@ package config
22
33 import (
44 "context"
5- "errors"
65 "os"
7- "path/filepath"
8- "slices"
9- "sort"
106 "time"
11- "unsafe"
127
8+ "github.com/bnema/kvconf"
139 "github.com/bnema/neferwl/internal/ports"
1410 "github.com/bnema/zerowrap"
15- "golang.org/x/sys/unix"
1611 )
1712
18-var directoryPollInterval = 2 * time.Second
13+// reloadDebounce is the quiet time after a change before the file is read:
14+// every write restarts it, so the file is never read half written.
15+const reloadDebounce = 100 * time.Millisecond
1916
2017 func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
2118 f, err := os.Open(path)
@@ -35,184 +32,53 @@ func loadRaw(path string) map[string]string {
3532 return raw
3633 }
3734
38-// diff lists keys added, removed or changed, sorted.
39-func 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.
59-func 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.
35+// Watch reloads the config when the file at path changes and sends each
36+// changed config on out. kvconf watches the parent directory, so atomic file
37+// replacements are seen, follows symlinks (dotfiles) down to their target, and
38+// waits for a missing directory to appear.
8339 func 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
40+ ctx, cancel := context.WithCancel(ctx)
41+ defer cancel()
42+ // A pending signal already means "changed": one slot is enough.
43+ signals := make(chan struct{}, 1)
9944 last := loadRaw(path)
100- buf := make([]byte, 4096)
45+ watchErr := make(chan error, 1)
46+ go func() {
47+ watchErr <- kvconf.Watch(ctx, path, kvconf.WatchOptions{Debounce: reloadDebounce}, signals)
48+ }()
10149 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)
50+ select {
51+ case <-ctx.Done():
52+ cancel()
53+ return <-watchErr
54+ case err := <-watchErr:
55+ return err
56+ case <-signals:
16457 }
165- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
166- _, err = unix.Poll(fds, timeout)
167- if err == unix.EINTR {
58+ cfg, raw, warnings, err := loadFile(path)
59+ switch {
60+ case os.IsNotExist(err):
61+ log.Warn().Msg("config file removed; keeping current config")
62+ continue
63+ case err != nil:
64+ log.Warn().Err(err).Msg("config reload failed")
16865 continue
16966 }
170- if err != nil {
171- return err
67+ for _, w := range warnings {
68+ log.Warn().Int("line", w.Line).Msg(w.Msg)
17269 }
173- if fds[0].Revents&unix.POLLIN == 0 {
70+ changed := kvconf.Changed(last, raw)
71+ last = raw
72+ if len(changed) == 0 {
73+ log.Debug().Msg("config unchanged")
17474 continue
17575 }
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- }
76+ select {
77+ case <-ctx.Done():
78+ cancel()
79+ return <-watchErr
80+ case out <- ports.ConfigChanged{Config: cfg}:
21681 }
82+ log.Info().Strs("changed", changed).Msg("config reloaded")
21783 }
21884 }
@@ -2,20 +2,17 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "os"5 "os"
7- "path/filepath"
8- "slices"
9- "sort"
10 "time"6 "time"
11- "unsafe"
12 7
8+ "github.com/bnema/kvconf"
13 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
15- "golang.org/x/sys/unix"
16 )11 )
17 12
18-var directoryPollInterval = 2 * time.Second13+// reloadDebounce is the quiet time after a change before the file is read:
14+// every write restarts it, so the file is never read half written.
15+const reloadDebounce = 100 * time.Millisecond
19 16
20 func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {17 func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
21 f, err := os.Open(path)18 f, err := os.Open(path)
@@ -35,184 +32,53 @@ func loadRaw(path string) map[string]string {
35 return raw32 return raw
36 }33 }
37 34
38-// diff lists keys added, removed or changed, sorted.35+// Watch reloads the config when the file at path changes and sends each
39-func diff(old, cur map[string]string) []string {36+// changed config on out. kvconf watches the parent directory, so atomic file
40- var keys []string37+// replacements are seen, follows symlinks (dotfiles) down to their target, and
41- for k, v := range cur {38+// waits for a missing directory to appear.
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.
59-func 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.
83 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {39 func 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)40+ ctx, cancel := context.WithCancel(ctx)
85- if err != nil {41+ defer cancel()
86- return err42+ // A pending signal already means "changed": one slot is enough.
87- }43+ signals := make(chan struct{}, 1)
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)44 last := loadRaw(path)
100- buf := make([]byte, 4096)45+ watchErr := make(chan error, 1)
46+ go func() {
47+ watchErr <- kvconf.Watch(ctx, path, kvconf.WatchOptions{Debounce: reloadDebounce}, signals)
48+ }()
101 for {49 for {
102- if ctx.Err() != nil {50+ select {
103- return nil51+ case <-ctx.Done():
104- }52+ cancel()
105- if !retry.IsZero() && !time.Now().Before(retry) {53+ return <-watchErr
106- rewatch = true54+ case err := <-watchErr:
107- }55+ return err
108- if rewatch {56+ case <-signals:
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 }57 }
165- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}58+ cfg, raw, warnings, err := loadFile(path)
166- _, err = unix.Poll(fds, timeout)59+ switch {
167- if err == unix.EINTR {60+ case os.IsNotExist(err):
61+ log.Warn().Msg("config file removed; keeping current config")
62+ continue
63+ case err != nil:
64+ log.Warn().Err(err).Msg("config reload failed")
168 continue65 continue
169 }66 }
170- if err != nil {67+ for _, w := range warnings {
171- return err68+ log.Warn().Int("line", w.Line).Msg(w.Msg)
172 }69 }
173- if fds[0].Revents&unix.POLLIN == 0 {70+ changed := kvconf.Changed(last, raw)
71+ last = raw
72+ if len(changed) == 0 {
73+ log.Debug().Msg("config unchanged")
174 continue74 continue
175 }75 }
176- for {76+ select {
177- n, readErr := unix.Read(fd, buf)77+ case <-ctx.Done():
178- if readErr == unix.EAGAIN {78+ cancel()
179- break79+ return <-watchErr
180- }80+ case out <- ports.ConfigChanged{Config: cfg}:
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 }81 }
82+ log.Info().Strs("changed", changed).Msg("config reloaded")
217 }83 }
218 }84 }
M internal/adapters/config/watch_test.go +0 -28
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8- "slices"
98 "testing"
109 "time"
1110
@@ -26,9 +25,6 @@ func TestWatch(t *testing.T) {
2625 ctx, cancel := context.WithCancel(context.Background())
2726 out := make(chan ports.ConfigChanged, 8)
2827 done := make(chan error, 1)
29- old := directoryPollInterval
30- directoryPollInterval = 20 * time.Millisecond
31- defer func() { directoryPollInterval = old }()
3228 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
3329 defer func() {
3430 cancel()
@@ -163,9 +159,6 @@ func TestWatchSymlink(t *testing.T) {
163159 ctx, cancel := context.WithCancel(context.Background())
164160 out := make(chan ports.ConfigChanged, 8)
165161 done := make(chan error, 1)
166- old := directoryPollInterval
167- directoryPollInterval = 20 * time.Millisecond
168- defer func() { directoryPollInterval = old }()
169162 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
170163 defer func() {
171164 cancel()
@@ -237,24 +230,3 @@ func TestWatchSymlink(t *testing.T) {
237230 case <-time.After(250 * time.Millisecond):
238231 }
239232 }
240-
241-func TestWatchedFiles(t *testing.T) {
242- dir := t.TempDir()
243- path := filepath.Join(dir, "config")
244- if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
245- t.Fatal(got)
246- }
247- // A dangling link still names its target; a loop stops.
248- if err := os.Symlink("sub/../missing", path); err != nil {
249- t.Fatal(err)
250- }
251- if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
252- t.Fatal(got)
253- }
254- if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
255- t.Fatal(err)
256- }
257- if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
258- t.Fatal(got)
259- }
260-}
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8- "slices"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
@@ -26,9 +25,6 @@ func TestWatch(t *testing.T) {
26 ctx, cancel := context.WithCancel(context.Background())25 ctx, cancel := context.WithCancel(context.Background())
27 out := make(chan ports.ConfigChanged, 8)26 out := make(chan ports.ConfigChanged, 8)
28 done := make(chan error, 1)27 done := make(chan error, 1)
29- old := directoryPollInterval
30- directoryPollInterval = 20 * time.Millisecond
31- defer func() { directoryPollInterval = old }()
32 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()28 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
33 defer func() {29 defer func() {
34 cancel()30 cancel()
@@ -163,9 +159,6 @@ func TestWatchSymlink(t *testing.T) {
163 ctx, cancel := context.WithCancel(context.Background())159 ctx, cancel := context.WithCancel(context.Background())
164 out := make(chan ports.ConfigChanged, 8)160 out := make(chan ports.ConfigChanged, 8)
165 done := make(chan error, 1)161 done := make(chan error, 1)
166- old := directoryPollInterval
167- directoryPollInterval = 20 * time.Millisecond
168- defer func() { directoryPollInterval = old }()
169 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()162 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
170 defer func() {163 defer func() {
171 cancel()164 cancel()
@@ -237,24 +230,3 @@ func TestWatchSymlink(t *testing.T) {
237 case <-time.After(250 * time.Millisecond):230 case <-time.After(250 * time.Millisecond):
238 }231 }
239 }232 }
240-
241-func TestWatchedFiles(t *testing.T) {
242- dir := t.TempDir()
243- path := filepath.Join(dir, "config")
244- if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
245- t.Fatal(got)
246- }
247- // A dangling link still names its target; a loop stops.
248- if err := os.Symlink("sub/../missing", path); err != nil {
249- t.Fatal(err)
250- }
251- if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
252- t.Fatal(got)
253- }
254- if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
255- t.Fatal(err)
256- }
257- if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
258- t.Fatal(got)
259- }
260-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close