Merge pull request 'refactor(config): watch and save the config with kvconf' (#35) from refactor/kvconf into main
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`). | |||
| 4 | 4 | ||
| 5 | 5 | ## 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 | 8 | - Lists are comma-separated; booleans are `on` / `off`. | |
| 9 | 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 | 10 | - 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 | ## Format | 5 | ## 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 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | + | github.com/bnema/kvconf v0.1.2 | |
| 7 | 8 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 8 | 9 | github.com/bnema/purego-libwayland v0.9.0 | |
| 9 | 10 | 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.0 | 6 | 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.1 | 8 | github.com/bnema/purego v0.13.0-bnema.1 | ||
| 8 | github.com/bnema/purego-libwayland v0.9.0 | 9 | github.com/bnema/purego-libwayland v0.9.0 | ||
| 9 | github.com/bnema/purego-vulkan v0.6.0 | 10 | 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 @@ | |||
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "bufio" | |
| 5 | + | "bytes" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | + | "io/fs" | |
| 9 | 10 | "math" | |
| 10 | 11 | "os" | |
| 11 | 12 | "path/filepath" | |
| @@ -16,7 +17,9 @@ import ( | |||
| 16 | 17 | "strings" | |
| 17 | 18 | "time" | |
| 18 | 19 | "unicode" | |
| 20 | + | "unicode/utf8" | |
| 19 | 21 | ||
| 22 | + | "github.com/bnema/kvconf" | |
| 20 | 23 | "github.com/bnema/neferwl/internal/ports" | |
| 21 | 24 | ) | |
| 22 | 25 | ||
| @@ -222,7 +225,7 @@ func Defaults() ports.Config { | |||
| 222 | 225 | // LoadDefault loads DefaultPath; a missing file means defaults. | |
| 223 | 226 | func LoadDefault() (ports.Config, []Warning, error) { | |
| 224 | 227 | c, w, err := Load(DefaultPath()) | |
| 225 | - | if os.IsNotExist(err) { | |
| 228 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 226 | 229 | return Defaults(), nil, nil | |
| 227 | 230 | } | |
| 228 | 231 | return c, w, err | |
| @@ -230,12 +233,20 @@ func LoadDefault() (ports.Config, []Warning, error) { | |||
| 230 | 233 | ||
| 231 | 234 | // Load reads a config file. The error is only for I/O; bad lines become warnings. | |
| 232 | 235 | func Load(path string) (ports.Config, []Warning, error) { | |
| 233 | - | f, err := os.Open(path) | |
| 236 | + | c, _, w, err := loadFile(path) | |
| 234 | 237 | if err != nil { | |
| 235 | 238 | return Defaults(), nil, err | |
| 236 | 239 | } | |
| 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) | |
| 239 | 250 | } | |
| 240 | 251 | ||
| 241 | 252 | // 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 | 329 | // Parse reads `key = value` lines on top of the defaults. | |
| 319 | 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 | 336 | return c, w, err | |
| 322 | 337 | } | |
| 323 | 338 | ||
| 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) { | |
| 326 | 342 | c := Defaults() | |
| 327 | 343 | var warnings []Warning | |
| 328 | 344 | raw := map[string]string{} | |
| @@ -333,21 +349,33 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 333 | 349 | workspaces := map[string]int{} | |
| 334 | 350 | rules := map[string]*ruleEntry{} | |
| 335 | 351 | 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++ | |
| 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 | 376 | warn := func(format string, a ...any) { | |
| 343 | 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 | 379 | if combo, ok := strings.CutPrefix(key, "bind."); ok { | |
| 352 | 380 | if strings.HasSuffix(combo, "+") && strings.HasPrefix(value, "=") { | |
| 353 | 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 | 580 | override() | |
| 553 | 581 | } | |
| 554 | - | if err := scanner.Err(); err != nil { | |
| 555 | - | return c, raw, warnings, err | |
| 556 | - | } | |
| 557 | 582 | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 558 | 583 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 559 | 584 | var ruleWarnings []Warning | |
| @@ -1185,17 +1210,6 @@ func splitList(v string) []string { | |||
| 1185 | 1210 | 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 | 1213 | func checkAction(v string) error { | |
| 1200 | 1214 | if rest, ok := strings.CutPrefix(v, "spawn "); ok { | |
| 1201 | 1215 | if len(ports.SplitCommands(rest)) == 0 { | |
| @@ -2,10 +2,11 @@ | |||||
| 2 | package config | 2 | 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, nil | 229 | return Defaults(), nil, nil | ||
| 227 | } | 230 | } | ||
| 228 | return c, w, err | 231 | 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, err | 238 | 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 any | 252 | // 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, err | 336 | 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 []Warning | 343 | 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 []*ruleEntry | 351 | 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 | - | continue | 356 | + | 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 []Warning | 584 | var ruleWarnings []Warning | ||
| @@ -1185,17 +1210,6 @@ func splitList(v string) []string { | |||||
| 1185 | return list | 1210 | 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 ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "reflect" | |
| 9 | 9 | "strings" | |
| 10 | + | "syscall" | |
| 10 | 11 | "testing" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -831,3 +832,71 @@ func TestOutputModeLineKeepsAnchor(t *testing.T) { | |||
| 831 | 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 | + | } | |
| @@ -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 | |||
| 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,20 +103,20 @@ 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 | } | |
| 114 | 116 | ||
| 115 | 117 | func parseRaw(t *testing.T, s string) (cfg any, raw map[string]string, w []Warning) { | |
| 116 | 118 | t.Helper() | |
| 117 | - | c, raw, w, err := parse(strings.NewReader(s)) | |
| 119 | + | c, raw, w, err := parseBytes([]byte(s)) | |
| 118 | 120 | if err != nil { | |
| 119 | 121 | t.Fatal(err) | |
| 120 | 122 | } | |
| @@ -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 @@ | |||
| 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/scalestore_test.go +14 -0
| @@ -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 | + | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "slices" | |
| 9 | - | "sort" | |
| 6 | + | "io/fs" | |
| 10 | 7 | "time" | |
| 11 | - | "unsafe" | |
| 12 | 8 | ||
| 9 | + | "github.com/bnema/kvconf" | |
| 13 | 10 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 11 | "github.com/bnema/zerowrap" | |
| 15 | - | "golang.org/x/sys/unix" | |
| 16 | 12 | ) | |
| 17 | 13 | ||
| 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 | |
| 28 | 17 | ||
| 29 | 18 | // loadRaw returns the effective keys of the file at startup; missing means none. | |
| 30 | 19 | func loadRaw(path string) map[string]string { | |
| @@ -35,184 +24,51 @@ func loadRaw(path string) map[string]string { | |||
| 35 | 24 | return raw | |
| 36 | 25 | } | |
| 37 | 26 | ||
| 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. | |
| 83 | 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) | |
| 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) | |
| 99 | 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 | 41 | 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: | |
| 164 | 48 | } | |
| 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") | |
| 168 | 56 | continue | |
| 169 | 57 | } | |
| 170 | - | if err != nil { | |
| 171 | - | return err | |
| 58 | + | for _, w := range warnings { | |
| 59 | + | 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 | 65 | continue | |
| 175 | 66 | } | |
| 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}: | |
| 216 | 71 | } | |
| 72 | + | log.Info().Strs("changed", changed).Msg("config reloaded") | |
| 217 | 73 | } | |
| 218 | 74 | } | |
| @@ -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.Second | 14 | + | // 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 raw | 24 | 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 []string | 29 | + | // 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 err | 34 | + | // 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 nil | 43 | + | case <-ctx.Done(): |
| 104 | - | } | 44 | + | return <-watchErr |
| 105 | - | if !retry.IsZero() && !time.Now().Before(retry) { | 45 | + | case err := <-watchErr: |
| 106 | - | rewatch = true | 46 | + | 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 | continue | 56 | continue | ||
| 169 | } | 57 | } | ||
| 170 | - | if err != nil { | 58 | + | for _, w := range warnings { |
| 171 | - | return err | 59 | + | 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 | continue | 65 | 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 | - | break | 70 | + | 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 ( | |||
| 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() | |
| @@ -227,7 +220,7 @@ func TestWatchSymlink(t *testing.T) { | |||
| 227 | 220 | t.Fatal(err) | |
| 228 | 221 | } | |
| 229 | 222 | 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 | |
| 231 | 224 | until("#abcdef", func(body string) { write(moved, body) }) | |
| 232 | 225 | // The old target no longer matters. | |
| 233 | 226 | write(target, "background = #123456\n") | |
| @@ -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() | ||
| @@ -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 retry | 223 | + | 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 | - | } | |||