detached 680a5e7

Merge pull request 'refactor(config): watch and save the config with kvconf' (#35) from refactor/kvconf into main

commit
680a5e7c80640520af6ea05d51739f576d856b0f
author
bnema · · 2026-10-10 08:55 UTC
merge of
60af2d3 + 3462c76 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
10 files · +187 -386
M docs/config.md +1 -1
@@ -4,7 +4,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
44
55 ## Format
66
7-- One `key = value` per line; `#` starts a comment.
7+- One `key = value` per line. A line starting with `#` is a comment, and so is a `#` after a space, unless it is inside a quoted word (`"a # b"`).
88 - Lists are comma-separated; booleans are `on` / `off`.
99 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.
1010 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.
@@ -4,7 +4,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
4 4
5 ## Format5 ## Format
6 6
7-- One `key = value` per line; `#` starts a comment.7+- One `key = value` per line. A line starting with `#` is a comment, and so is a `#` after a space, unless it is inside a quoted word (`"a # b"`).
8 - Lists are comma-separated; booleans are `on` / `off`.8 - Lists are comma-separated; booleans are `on` / `off`.
9 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.9 - A missing file or key means the default. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. A duplicate key warns and the last one wins.
10 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.10 - Every key applies live when the file is saved; windows stay open. `startup` and `xwayland` take effect at the next start.
M go.mod +1 -0
@@ -4,6 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.2
78 github.com/bnema/purego v0.13.0-bnema.1
89 github.com/bnema/purego-libwayland v0.9.0
910 github.com/bnema/purego-vulkan v0.6.0
@@ -4,6 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.2
7 github.com/bnema/purego v0.13.0-bnema.18 github.com/bnema/purego v0.13.0-bnema.1
8 github.com/bnema/purego-libwayland v0.9.09 github.com/bnema/purego-libwayland v0.9.0
9 github.com/bnema/purego-vulkan v0.6.010 github.com/bnema/purego-vulkan v0.6.0
M go.sum +2 -0 generated file: collapsed
M internal/adapters/config/config.go +47 -33
@@ -2,10 +2,11 @@
22 package config
33
44 import (
5- "bufio"
5+ "bytes"
66 "errors"
77 "fmt"
88 "io"
9+ "io/fs"
910 "math"
1011 "os"
1112 "path/filepath"
@@ -16,7 +17,9 @@ import (
1617 "strings"
1718 "time"
1819 "unicode"
20+ "unicode/utf8"
1921
22+ "github.com/bnema/kvconf"
2023 "github.com/bnema/neferwl/internal/ports"
2124 )
2225
@@ -222,7 +225,7 @@ func Defaults() ports.Config {
222225 // LoadDefault loads DefaultPath; a missing file means defaults.
223226 func LoadDefault() (ports.Config, []Warning, error) {
224227 c, w, err := Load(DefaultPath())
225- if os.IsNotExist(err) {
228+ if errors.Is(err, fs.ErrNotExist) {
226229 return Defaults(), nil, nil
227230 }
228231 return c, w, err
@@ -230,12 +233,20 @@ func LoadDefault() (ports.Config, []Warning, error) {
230233
231234 // Load reads a config file. The error is only for I/O; bad lines become warnings.
232235 func Load(path string) (ports.Config, []Warning, error) {
233- f, err := os.Open(path)
236+ c, _, w, err := loadFile(path)
234237 if err != nil {
235238 return Defaults(), nil, err
236239 }
237- defer f.Close()
238- return Parse(f)
240+ return c, w, nil
241+}
242+
243+// loadFile reads path without blocking on a FIFO or device, then parses it.
244+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
245+ data, err := kvconf.ReadFile(path, kvconf.Options{MaxBytes: -1})
246+ if err != nil {
247+ return ports.Config{}, nil, nil, err
248+ }
249+ return parseBytes(data)
239250 }
240251
241252 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
@@ -317,12 +328,17 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
317328
318329 // Parse reads `key = value` lines on top of the defaults.
319330 func Parse(r io.Reader) (ports.Config, []Warning, error) {
320- c, _, w, err := parse(r)
331+ data, err := io.ReadAll(r)
332+ if err != nil {
333+ return Defaults(), nil, err
334+ }
335+ c, _, w, err := parseBytes(data)
321336 return c, w, err
322337 }
323338
324-// parse also returns the effective key/value pairs, used to log what changed on reload.
325-func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
339+// parseBytes also returns the effective key/value pairs, used to log what
340+// changed on reload.
341+func parseBytes(data []byte) (ports.Config, map[string]string, []Warning, error) {
326342 c := Defaults()
327343 var warnings []Warning
328344 raw := map[string]string{}
@@ -333,21 +349,33 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333349 workspaces := map[string]int{}
334350 rules := map[string]*ruleEntry{}
335351 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
352+ // An invalid byte (a Latin-1 comment, say) costs a warning, not the
353+ // whole file: the rest still applies, like any other bad line.
354+ if !utf8.Valid(data) {
355+ line := 1
356+ for _, l := range bytes.Split(data, []byte("\n")) {
357+ if !utf8.Valid(l) {
358+ break
359+ }
360+ line++
341361 }
362+ data = bytes.ToValidUTF8(data, []byte("\uFFFD"))
363+ warnings = append(warnings, Warning{Line: line, Msg: "not valid UTF-8; invalid bytes replaced"})
364+ }
365+ // kvconf owns the line syntax; the values stay raw (neferwl has no
366+ // quoting) and repeated keys are reported below with their own wording.
367+ entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
368+ if err != nil {
369+ return c, raw, nil, err
370+ }
371+ for _, w := range lineWarnings {
372+ warnings = append(warnings, Warning{Line: w.Line, Msg: w.Msg})
373+ }
374+ for _, e := range entries {
375+ n, key, value := e.Line, e.Key, e.Value
342376 warn := func(format string, a ...any) {
343377 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
344378 }
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- }
351379 if combo, ok := strings.CutPrefix(key, "bind."); ok {
352380 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
353381 warn("%s: write %sequal instead of %s=", key, combo, combo)
@@ -551,9 +579,6 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
551579 }
552580 override()
553581 }
554- if err := scanner.Err(); err != nil {
555- return c, raw, warnings, err
556- }
557582 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
558583 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
559584 var ruleWarnings []Warning
@@ -1185,17 +1210,6 @@ func splitList(v string) []string {
11851210 return list
11861211 }
11871212
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-
11991213 func checkAction(v string) error {
12001214 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
12011215 if len(ports.SplitCommands(rest)) == 0 {
@@ -2,10 +2,11 @@
2 package config2 package config
3 3
4 import (4 import (
5- "bufio"5+ "bytes"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io"8 "io"
9+ "io/fs"
9 "math"10 "math"
10 "os"11 "os"
11 "path/filepath"12 "path/filepath"
@@ -16,7 +17,9 @@ import (
16 "strings"17 "strings"
17 "time"18 "time"
18 "unicode"19 "unicode"
20+ "unicode/utf8"
19 21
22+ "github.com/bnema/kvconf"
20 "github.com/bnema/neferwl/internal/ports"23 "github.com/bnema/neferwl/internal/ports"
21 )24 )
22 25
@@ -222,7 +225,7 @@ func Defaults() ports.Config {
222 // LoadDefault loads DefaultPath; a missing file means defaults.225 // LoadDefault loads DefaultPath; a missing file means defaults.
223 func LoadDefault() (ports.Config, []Warning, error) {226 func LoadDefault() (ports.Config, []Warning, error) {
224 c, w, err := Load(DefaultPath())227 c, w, err := Load(DefaultPath())
225- if os.IsNotExist(err) {228+ if errors.Is(err, fs.ErrNotExist) {
226 return Defaults(), nil, nil229 return Defaults(), nil, nil
227 }230 }
228 return c, w, err231 return c, w, err
@@ -230,12 +233,20 @@ func LoadDefault() (ports.Config, []Warning, error) {
230 233
231 // Load reads a config file. The error is only for I/O; bad lines become warnings.234 // Load reads a config file. The error is only for I/O; bad lines become warnings.
232 func Load(path string) (ports.Config, []Warning, error) {235 func Load(path string) (ports.Config, []Warning, error) {
233- f, err := os.Open(path)236+ c, _, w, err := loadFile(path)
234 if err != nil {237 if err != nil {
235 return Defaults(), nil, err238 return Defaults(), nil, err
236 }239 }
237- defer f.Close()240+ return c, w, nil
238- return Parse(f)241+}
242+
243+// loadFile reads path without blocking on a FIFO or device, then parses it.
244+func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
245+ data, err := kvconf.ReadFile(path, kvconf.Options{MaxBytes: -1})
246+ if err != nil {
247+ return ports.Config{}, nil, nil, err
248+ }
249+ return parseBytes(data)
239 }250 }
240 251
241 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any252 // maxOutputOffset bounds output.<name>.offset (logical px), far beyond any
@@ -317,12 +328,17 @@ func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string,
317 328
318 // Parse reads `key = value` lines on top of the defaults.329 // Parse reads `key = value` lines on top of the defaults.
319 func Parse(r io.Reader) (ports.Config, []Warning, error) {330 func Parse(r io.Reader) (ports.Config, []Warning, error) {
320- c, _, w, err := parse(r)331+ data, err := io.ReadAll(r)
332+ if err != nil {
333+ return Defaults(), nil, err
334+ }
335+ c, _, w, err := parseBytes(data)
321 return c, w, err336 return c, w, err
322 }337 }
323 338
324-// parse also returns the effective key/value pairs, used to log what changed on reload.339+// parseBytes also returns the effective key/value pairs, used to log what
325-func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {340+// changed on reload.
341+func parseBytes(data []byte) (ports.Config, map[string]string, []Warning, error) {
326 c := Defaults()342 c := Defaults()
327 var warnings []Warning343 var warnings []Warning
328 raw := map[string]string{}344 raw := map[string]string{}
@@ -333,21 +349,33 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
333 workspaces := map[string]int{}349 workspaces := map[string]int{}
334 rules := map[string]*ruleEntry{}350 rules := map[string]*ruleEntry{}
335 var ruleOrder []*ruleEntry351 var ruleOrder []*ruleEntry
336- scanner := bufio.NewScanner(r)352+ // An invalid byte (a Latin-1 comment, say) costs a warning, not the
337- for n := 1; scanner.Scan(); n++ {353+ // whole file: the rest still applies, like any other bad line.
338- line := strings.TrimSpace(scanner.Text())354+ if !utf8.Valid(data) {
339- if line == "" || strings.HasPrefix(line, "#") {355+ line := 1
340- continue356+ for _, l := range bytes.Split(data, []byte("\n")) {
357+ if !utf8.Valid(l) {
358+ break
359+ }
360+ line++
341 }361 }
362+ data = bytes.ToValidUTF8(data, []byte("\uFFFD"))
363+ warnings = append(warnings, Warning{Line: line, Msg: "not valid UTF-8; invalid bytes replaced"})
364+ }
365+ // kvconf owns the line syntax; the values stay raw (neferwl has no
366+ // quoting) and repeated keys are reported below with their own wording.
367+ entries, lineWarnings, err := kvconf.ParseBytes(data, kvconf.Options{AllowDuplicates: true, RawValues: true, MaxBytes: -1, MaxLine: -1})
368+ if err != nil {
369+ return c, raw, nil, err
370+ }
371+ for _, w := range lineWarnings {
372+ warnings = append(warnings, Warning{Line: w.Line, Msg: w.Msg})
373+ }
374+ for _, e := range entries {
375+ n, key, value := e.Line, e.Key, e.Value
342 warn := func(format string, a ...any) {376 warn := func(format string, a ...any) {
343 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})377 warnings = append(warnings, Warning{Line: n, Msg: fmt.Sprintf(format, a...)})
344 }378 }
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 {379 if combo, ok := strings.CutPrefix(key, "bind."); ok {
352 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {380 if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") {
353 warn("%s: write %sequal instead of %s=", key, combo, combo)381 warn("%s: write %sequal instead of %s=", key, combo, combo)
@@ -551,9 +579,6 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
551 }579 }
552 override()580 override()
553 }581 }
554- if err := scanner.Err(); err != nil {
555- return c, raw, warnings, err
556- }
557 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)582 warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
558 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)583 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
559 var ruleWarnings []Warning584 var ruleWarnings []Warning
@@ -1185,17 +1210,6 @@ func splitList(v string) []string {
1185 return list1210 return list
1186 }1211 }
1187 1212
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 {1213 func checkAction(v string) error {
1200 if rest, ok := strings.CutPrefix(v, "spawn "); ok {1214 if rest, ok := strings.CutPrefix(v, "spawn "); ok {
1201 if len(ports.SplitCommands(rest)) == 0 {1215 if len(ports.SplitCommands(rest)) == 0 {
M internal/adapters/config/config_test.go +69 -0
@@ -7,6 +7,7 @@ import (
77 "path/filepath"
88 "reflect"
99 "strings"
10+ "syscall"
1011 "testing"
1112 "time"
1213
@@ -831,3 +832,71 @@ func TestOutputModeLineKeepsAnchor(t *testing.T) {
831832 t.Fatalf("%+v", o)
832833 }
833834 }
835+
836+func TestFileSyntax(t *testing.T) {
837+ for _, tc := range []struct {
838+ name, in string
839+ check func(t *testing.T, c ports.Config)
840+ warnings []string
841+ }{
842+ {
843+ name: "invalid UTF-8 costs a warning, not the file",
844+ in: "# caf\xe9\nborder.width = 5\n",
845+ check: func(t *testing.T, c ports.Config) {
846+ if c.Border.Width != 5 {
847+ t.Fatalf("border.width = %d", c.Border.Width)
848+ }
849+ },
850+ warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
851+ },
852+ {
853+ name: "output name with spaces",
854+ in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
855+ check: func(t *testing.T, c ports.Config) {
856+ if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
857+ t.Fatalf("%+v", c.Outputs)
858+ }
859+ },
860+ },
861+ {
862+ // A '#' inside a quoted word is kept, as a shell would.
863+ name: "comment after a quoted word",
864+ in: "startup = notify-send \"a # b\" # note\n",
865+ check: func(t *testing.T, c ports.Config) {
866+ if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
867+ t.Fatalf("startup: %q", c.Startup)
868+ }
869+ },
870+ },
871+ } {
872+ t.Run(tc.name, func(t *testing.T) {
873+ c, w := parseString(t, tc.in)
874+ got := make([]string, 0, len(w))
875+ for _, x := range w {
876+ got = append(got, x.String())
877+ }
878+ if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
879+ t.Fatalf("warnings %q, want %q", got, tc.warnings)
880+ }
881+ tc.check(t, c)
882+ })
883+ }
884+}
885+
886+// A FIFO in place of the config fails at once instead of blocking startup.
887+func TestLoadRejectsFIFO(t *testing.T) {
888+ path := filepath.Join(t.TempDir(), "config")
889+ if err := syscall.Mkfifo(path, 0o600); err != nil {
890+ t.Skip(err)
891+ }
892+ done := make(chan error, 1)
893+ go func() { _, _, err := Load(path); done <- err }()
894+ select {
895+ case err := <-done:
896+ if err == nil {
897+ t.Fatal("FIFO loaded")
898+ }
899+ case <-time.After(5 * time.Second):
900+ t.Fatal("Load blocked on a FIFO")
901+ }
902+}
@@ -7,6 +7,7 @@ import (
7 "path/filepath"7 "path/filepath"
8 "reflect"8 "reflect"
9 "strings"9 "strings"
10+ "syscall"
10 "testing"11 "testing"
11 "time"12 "time"
12 13
@@ -831,3 +832,71 @@ func TestOutputModeLineKeepsAnchor(t *testing.T) {
831 t.Fatalf("%+v", o)832 t.Fatalf("%+v", o)
832 }833 }
833 }834 }
835+
836+func TestFileSyntax(t *testing.T) {
837+ for _, tc := range []struct {
838+ name, in string
839+ check func(t *testing.T, c ports.Config)
840+ warnings []string
841+ }{
842+ {
843+ name: "invalid UTF-8 costs a warning, not the file",
844+ in: "# caf\xe9\nborder.width = 5\n",
845+ check: func(t *testing.T, c ports.Config) {
846+ if c.Border.Width != 5 {
847+ t.Fatalf("border.width = %d", c.Border.Width)
848+ }
849+ },
850+ warnings: []string{"line 1: not valid UTF-8; invalid bytes replaced"},
851+ },
852+ {
853+ name: "output name with spaces",
854+ in: "output.LG Electronics 27GR95UM.scale = 1.5\n",
855+ check: func(t *testing.T, c ports.Config) {
856+ if len(c.Outputs) != 1 || c.Outputs[0].Name != "LG Electronics 27GR95UM" || c.Outputs[0].Scale != 1.5 {
857+ t.Fatalf("%+v", c.Outputs)
858+ }
859+ },
860+ },
861+ {
862+ // A '#' inside a quoted word is kept, as a shell would.
863+ name: "comment after a quoted word",
864+ in: "startup = notify-send \"a # b\" # note\n",
865+ check: func(t *testing.T, c ports.Config) {
866+ if !reflect.DeepEqual(c.Startup, [][]string{{"notify-send", `"a`, "#", `b"`}}) {
867+ t.Fatalf("startup: %q", c.Startup)
868+ }
869+ },
870+ },
871+ } {
872+ t.Run(tc.name, func(t *testing.T) {
873+ c, w := parseString(t, tc.in)
874+ got := make([]string, 0, len(w))
875+ for _, x := range w {
876+ got = append(got, x.String())
877+ }
878+ if len(got) != len(tc.warnings) || (len(got) > 0 && !reflect.DeepEqual(got, tc.warnings)) {
879+ t.Fatalf("warnings %q, want %q", got, tc.warnings)
880+ }
881+ tc.check(t, c)
882+ })
883+ }
884+}
885+
886+// A FIFO in place of the config fails at once instead of blocking startup.
887+func TestLoadRejectsFIFO(t *testing.T) {
888+ path := filepath.Join(t.TempDir(), "config")
889+ if err := syscall.Mkfifo(path, 0o600); err != nil {
890+ t.Skip(err)
891+ }
892+ done := make(chan error, 1)
893+ go func() { _, _, err := Load(path); done <- err }()
894+ select {
895+ case err := <-done:
896+ if err == nil {
897+ t.Fatal("FIFO loaded")
898+ }
899+ case <-time.After(5 * time.Second):
900+ t.Fatal("Load blocked on a FIFO")
901+ }
902+}
M internal/adapters/config/rules_test.go +6 -4
@@ -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,20 +103,20 @@ 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 }
114116
115117 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
116118 t.Helper()
117- c, raw, w, err := parse(strings.NewReader(s))
119+ c, raw, w, err := parseBytes([]byte(s))
118120 if err != nil {
119121 t.Fatal(err)
120122 }
@@ -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,20 +103,20 @@ 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 }
114 116
115 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {117 func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) {
116 t.Helper()118 t.Helper()
117- c, raw, w, err := parse(strings.NewReader(s))119+ c, raw, w, err := parseBytes([]byte(s))
118 if err != nil {120 if err != nil {
119 t.Fatal(err)121 t.Fatal(err)
120 }122 }
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/scalestore_test.go +14 -0
@@ -117,3 +117,17 @@ func TestFormatScaleRoundTrip(t *testing.T) {
117117 }
118118 }
119119 }
120+
121+// Output names may hold spaces (EDID model names).
122+func TestSaveOutputScaleSpacedName(t *testing.T) {
123+ path := filepath.Join(t.TempDir(), "config")
124+ if err := os.WriteFile(path, []byte("output.LG Electronics 27GR95UM.scale = 2\n"), 0o600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := (ScaleStore{Path: path}).SaveOutputScale("LG Electronics 27GR95UM", 1.5); err != nil {
128+ t.Fatal(err)
129+ }
130+ if got, _ := os.ReadFile(path); string(got) != "output.LG Electronics 27GR95UM.scale = 1.5\n" {
131+ t.Fatalf("%q", got)
132+ }
133+}
@@ -117,3 +117,17 @@ func TestFormatScaleRoundTrip(t *testing.T) {
117 }117 }
118 }118 }
119 }119 }
120+
121+// Output names may hold spaces (EDID model names).
122+func TestSaveOutputScaleSpacedName(t *testing.T) {
123+ path := filepath.Join(t.TempDir(), "config")
124+ if err := os.WriteFile(path, []byte("output.LG Electronics 27GR95UM.scale = 2\n"), 0o600); err != nil {
125+ t.Fatal(err)
126+ }
127+ if err := (ScaleStore{Path: path}).SaveOutputScale("LG Electronics 27GR95UM", 1.5); err != nil {
128+ t.Fatal(err)
129+ }
130+ if got, _ := os.ReadFile(path); string(got) != "output.LG Electronics 27GR95UM.scale = 1.5\n" {
131+ t.Fatalf("%q", got)
132+ }
133+}
M internal/adapters/config/watch.go +41 -185
@@ -3,28 +3,17 @@ package config
33 import (
44 "context"
55 "errors"
6- "os"
7- "path/filepath"
8- "slices"
9- "sort"
6+ "io/fs"
107 "time"
11- "unsafe"
128
9+ "github.com/bnema/kvconf"
1310 "github.com/bnema/neferwl/internal/ports"
1411 "github.com/bnema/zerowrap"
15- "golang.org/x/sys/unix"
1612 )
1713
18-var directoryPollInterval = 2 * time.Second
19-
20-func 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-}
14+// reloadDebounce is the quiet time after a change before the file is read:
15+// every write restarts it, so the file is never read half written.
16+const reloadDebounce = 100 * time.Millisecond
2817
2918 // loadRaw returns the effective keys of the file at startup; missing means none.
3019 func loadRaw(path string) map[string]string {
@@ -35,184 +24,51 @@ func loadRaw(path string) map[string]string {
3524 return raw
3625 }
3726
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.
27+// Watch reloads the config when the file at path changes and sends each
28+// changed config on out. kvconf watches the parent directory, so atomic file
29+// replacements are seen, follows symlinks (dotfiles) down to their target, and
30+// waits for a missing directory to appear.
8331 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
32+ ctx, cancel := context.WithCancel(ctx)
33+ defer cancel()
34+ // A pending signal already means "changed": one slot is enough.
35+ signals := make(chan struct{}, 1)
9936 last := loadRaw(path)
100- buf := make([]byte, 4096)
37+ watchErr := make(chan error, 1)
38+ go func() {
39+ watchErr <- kvconf.Watch(ctx, path, kvconf.WatchOptions{Debounce: reloadDebounce}, signals)
40+ }()
10141 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)
42+ select {
43+ case <-ctx.Done():
44+ return <-watchErr
45+ case err := <-watchErr:
46+ return err
47+ case <-signals:
16448 }
165- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
166- _, err = unix.Poll(fds, timeout)
167- if err == unix.EINTR {
49+ cfg, raw, warnings, err := loadFile(path)
50+ switch {
51+ case errors.Is(err, fs.ErrNotExist):
52+ log.Warn().Msg("config file removed; keeping current config")
53+ continue
54+ case err != nil:
55+ log.Warn().Err(err).Msg("config reload failed")
16856 continue
16957 }
170- if err != nil {
171- return err
58+ for _, w := range warnings {
59+ log.Warn().Int("line", w.Line).Msg(w.Msg)
17260 }
173- if fds[0].Revents&unix.POLLIN == 0 {
61+ changed := kvconf.Changed(last, raw)
62+ last = raw
63+ if len(changed) == 0 {
64+ log.Debug().Msg("config unchanged")
17465 continue
17566 }
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- }
67+ select {
68+ case <-ctx.Done():
69+ return <-watchErr
70+ case out <- ports.ConfigChanged{Config: cfg}:
21671 }
72+ log.Info().Strs("changed", changed).Msg("config reloaded")
21773 }
21874 }
@@ -3,28 +3,17 @@ package config
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "os"6+ "io/fs"
7- "path/filepath"
8- "slices"
9- "sort"
10 "time"7 "time"
11- "unsafe"
12 8
9+ "github.com/bnema/kvconf"
13 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
15- "golang.org/x/sys/unix"
16 )12 )
17 13
18-var directoryPollInterval = 2 * time.Second14+// reloadDebounce is the quiet time after a change before the file is read:
19-15+// every write restarts it, so the file is never read half written.
20-func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {16+const reloadDebounce = 100 * time.Millisecond
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 17
29 // loadRaw returns the effective keys of the file at startup; missing means none.18 // loadRaw returns the effective keys of the file at startup; missing means none.
30 func loadRaw(path string) map[string]string {19 func loadRaw(path string) map[string]string {
@@ -35,184 +24,51 @@ func loadRaw(path string) map[string]string {
35 return raw24 return raw
36 }25 }
37 26
38-// diff lists keys added, removed or changed, sorted.27+// Watch reloads the config when the file at path changes and sends each
39-func diff(old, cur map[string]string) []string {28+// changed config on out. kvconf watches the parent directory, so atomic file
40- var keys []string29+// replacements are seen, follows symlinks (dotfiles) down to their target, and
41- for k, v := range cur {30+// 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 {31 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)32+ ctx, cancel := context.WithCancel(ctx)
85- if err != nil {33+ defer cancel()
86- return err34+ // A pending signal already means "changed": one slot is enough.
87- }35+ 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)36 last := loadRaw(path)
100- buf := make([]byte, 4096)37+ watchErr := make(chan error, 1)
38+ go func() {
39+ watchErr <- kvconf.Watch(ctx, path, kvconf.WatchOptions{Debounce: reloadDebounce}, signals)
40+ }()
101 for {41 for {
102- if ctx.Err() != nil {42+ select {
103- return nil43+ case <-ctx.Done():
104- }44+ return <-watchErr
105- if !retry.IsZero() && !time.Now().Before(retry) {45+ case err := <-watchErr:
106- rewatch = true46+ return err
107- }47+ case <-signals:
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 }48 }
165- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}49+ cfg, raw, warnings, err := loadFile(path)
166- _, err = unix.Poll(fds, timeout)50+ switch {
167- if err == unix.EINTR {51+ case errors.Is(err, fs.ErrNotExist):
52+ log.Warn().Msg("config file removed; keeping current config")
53+ continue
54+ case err != nil:
55+ log.Warn().Err(err).Msg("config reload failed")
168 continue56 continue
169 }57 }
170- if err != nil {58+ for _, w := range warnings {
171- return err59+ log.Warn().Int("line", w.Line).Msg(w.Msg)
172 }60 }
173- if fds[0].Revents&unix.POLLIN == 0 {61+ changed := kvconf.Changed(last, raw)
62+ last = raw
63+ if len(changed) == 0 {
64+ log.Debug().Msg("config unchanged")
174 continue65 continue
175 }66 }
176- for {67+ select {
177- n, readErr := unix.Read(fd, buf)68+ case <-ctx.Done():
178- if readErr == unix.EAGAIN {69+ return <-watchErr
179- break70+ case out <- ports.ConfigChanged{Config: cfg}:
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 }71 }
72+ log.Info().Strs("changed", changed).Msg("config reloaded")
217 }73 }
218 }74 }
M internal/adapters/config/watch_test.go +1 -29
@@ -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()
@@ -227,7 +220,7 @@ func TestWatchSymlink(t *testing.T) {
227220 t.Fatal(err)
228221 }
229222 write(moved, "background = #000001\n")
230- time.Sleep(100 * time.Millisecond) // past the 20 ms retry
223+ time.Sleep(100 * time.Millisecond) // let the watch move back into the recreated directory
231224 until("#abcdef", func(body string) { write(moved, body) })
232225 // The old target no longer matters.
233226 write(target, "background = #123456\n")
@@ -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()
@@ -227,7 +220,7 @@ func TestWatchSymlink(t *testing.T) {
227 t.Fatal(err)220 t.Fatal(err)
228 }221 }
229 write(moved, "background = #000001\n")222 write(moved, "background = #000001\n")
230- time.Sleep(100 * time.Millisecond) // past the 20 ms retry223+ time.Sleep(100 * time.Millisecond) // let the watch move back into the recreated directory
231 until("#abcdef", func(body string) { write(moved, body) })224 until("#abcdef", func(body string) { write(moved, body) })
232 // The old target no longer matters.225 // The old target no longer matters.
233 write(target, "background = #123456\n")226 write(target, "background = #123456\n")
@@ -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 23

Tags 10

↑↓ moveenter openesc close