refactor(config): watch and save the config with kvconf
- commit
- e5fd3064b726697b1c4b190a0ec07951f457bf5c
- author
- brice · · 2026-10-10 02:59 UTC
- parent
- 60af2d3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +68 -368
M internal/adapters/config/config.go +16 -27
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "bufio" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "io" | |
| @@ -17,6 +16,7 @@ import ( | |||
| 17 | 16 | "time" | |
| 18 | 17 | "unicode" | |
| 19 | 18 | ||
| 19 | + | "github.com/bnema/kvconf" | |
| 20 | 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 | 333 | workspaces := map[string]int{} | |
| 334 | 334 | rules := map[string]*ruleEntry{} | |
| 335 | 335 | 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 | |
| 342 | 351 | warn := func(format string, a ...any) { | |
| 343 | 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 | 354 | if combo, ok := strings.CutPrefix(key, "bind."); ok { | |
| 352 | 355 | if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") { | |
| 353 | 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 | 555 | override() | |
| 553 | 556 | } | |
| 554 | - | if err := scanner.Err(); err != nil { | |
| 555 | - | return c, raw, warnings, err | |
| 556 | - | } | |
| 557 | 557 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 558 | 558 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 559 | 559 | var ruleWarnings []Warning | |
| @@ -1185,17 +1185,6 @@ func splitList(v string) []string { | |||
| 1185 | 1185 | 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 | 1188 | func checkAction(v string) error { | |
| 1200 | 1189 | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |
| 1201 | 1190 | if len(ports.SplitCommands(rest)) == 0 { | |
| @@ -2,7 +2,6 @@ | |||||
| 2 | package config | 2 | 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 []*ruleEntry | 335 | 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 | - | continue | 340 | + | 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 []Warning | 559 | var ruleWarnings []Warning | ||
| @@ -1185,17 +1185,6 @@ func splitList(v string) []string { | |||||
| 1185 | return list | 1185 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/kvconf" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestRulesInFileOrder(t *testing.T) { | |
| @@ -101,13 +103,13 @@ func TestRuleReloadDiff(t *testing.T) { | |||
| 101 | 103 | _, a, _ := parseRaw(t, "rule.a.app-id = foo\nrule.b.app-id = bar\n") | |
| 102 | 104 | _, b, _ := parseRaw(t, "rule.b.app-id = bar\nrule.a.app-id = foo\n") | |
| 103 | 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 | 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 | 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 | 113 | t.Fatal(d) | |
| 112 | 114 | } | |
| 113 | 115 | } | |
| @@ -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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "errors" | |
| 6 | 4 | "fmt" | |
| 7 | 5 | "math" | |
| 8 | - | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 6 | "strconv" | |
| 11 | - | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/kvconf" | |
| 12 | 9 | ) | |
| 13 | 10 | ||
| 14 | 11 | // ScaleStore writes output.<name>.scale into the config file, preserving | |
| @@ -17,110 +14,10 @@ type ScaleStore struct{ Path string } | |||
| 17 | 14 | ||
| 18 | 15 | // SaveOutputScale updates output.<name>.scale in the config file. The file is | |
| 19 | 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 | 18 | 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) | |
| 124 | 21 | } | |
| 125 | 22 | ||
| 126 | 23 | // 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 | 46 | 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 | - | } | |
| @@ -1,14 +1,11 @@ | |||||
| 1 | package config | 1 | 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, preserving | 11 | // 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 is | 15 | // 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.Path | 19 | + | 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 is | 23 | // 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 a | 46 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "slices" | |
| 9 | - | "sort" | |
| 10 | 6 | "time" | |
| 11 | - | "unsafe" | |
| 12 | 7 | ||
| 8 | + | "github.com/bnema/kvconf" | |
| 13 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 10 | "github.com/bnema/zerowrap" | |
| 15 | - | "golang.org/x/sys/unix" | |
| 16 | 11 | ) | |
| 17 | 12 | ||
| 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 | |
| 19 | 16 | ||
| 20 | 17 | func loadFile(path string) (ports.Config, map[string]string, []Warning, error) { | |
| 21 | 18 | f, err := os.Open(path) | |
| @@ -35,184 +32,53 @@ func loadRaw(path string) map[string]string { | |||
| 35 | 32 | return raw | |
| 36 | 33 | } | |
| 37 | 34 | ||
| 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. | |
| 83 | 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) | |
| 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) | |
| 99 | 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 | 49 | 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: | |
| 164 | 57 | } | |
| 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") | |
| 168 | 65 | continue | |
| 169 | 66 | } | |
| 170 | - | if err != nil { | |
| 171 | - | return err | |
| 67 | + | for _, w := range warnings { | |
| 68 | + | 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 | 74 | continue | |
| 175 | 75 | } | |
| 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}: | |
| 216 | 81 | } | |
| 82 | + | log.Info().Strs("changed", changed).Msg("config reloaded") | |
| 217 | 83 | } | |
| 218 | 84 | } | |
| @@ -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.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 | |||
| 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 raw | 32 | 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 []string | 37 | + | // 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 err | 42 | + | // 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 nil | 51 | + | case <-ctx.Done(): |
| 104 | - | } | 52 | + | cancel() |
| 105 | - | if !retry.IsZero() && !time.Now().Before(retry) { | 53 | + | return <-watchErr |
| 106 | - | rewatch = true | 54 | + | 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 | continue | 65 | continue | ||
| 169 | } | 66 | } | ||
| 170 | - | if err != nil { | 67 | + | for _, w := range warnings { |
| 171 | - | return err | 68 | + | 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 | continue | 74 | 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 | - | break | 79 | + | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | - | "slices" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -26,9 +25,6 @@ func TestWatch(t *testing.T) { | |||
| 26 | 25 | ctx, cancel := context.WithCancel(context.Background()) | |
| 27 | 26 | out := make(chan ports.ConfigChanged, 8) | |
| 28 | 27 | done := make(chan error, 1) | |
| 29 | - | old := directoryPollInterval | |
| 30 | - | directoryPollInterval = 20 * time.Millisecond | |
| 31 | - | defer func() { directoryPollInterval = old }() | |
| 32 | 28 | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |
| 33 | 29 | defer func() { | |
| 34 | 30 | cancel() | |
| @@ -163,9 +159,6 @@ func TestWatchSymlink(t *testing.T) { | |||
| 163 | 159 | ctx, cancel := context.WithCancel(context.Background()) | |
| 164 | 160 | out := make(chan ports.ConfigChanged, 8) | |
| 165 | 161 | done := make(chan error, 1) | |
| 166 | - | old := directoryPollInterval | |
| 167 | - | directoryPollInterval = 20 * time.Millisecond | |
| 168 | - | defer func() { directoryPollInterval = old }() | |
| 169 | 162 | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |
| 170 | 163 | defer func() { | |
| 171 | 164 | cancel() | |
| @@ -237,24 +230,3 @@ func TestWatchSymlink(t *testing.T) { | |||
| 237 | 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 | - | } | |
| @@ -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 | - | } | |||