refactor(config): parse and watch the config with kvconf
- commit
- cce3930ff6aa04ed7ca97223b712a35be0fd0b68
- author
- brice · · 2026-10-10 03:00 UTC
- parent
- cad1d13
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +228 -295
M go.mod +1 -0
| @@ -3,6 +3,7 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | + | github.com/bnema/kvconf v0.1.0 | |
| 6 | 7 | github.com/bnema/vev-vt v0.7.0 | |
| 7 | 8 | github.com/coder/websocket v1.8.15 | |
| 8 | 9 | github.com/quic-go/quic-go v0.62.0 | |
| @@ -3,6 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | + | github.com/bnema/kvconf v0.1.0 | |||
| 6 | github.com/bnema/vev-vt v0.7.0 | 7 | github.com/bnema/vev-vt v0.7.0 | ||
| 7 | github.com/coder/websocket v1.8.15 | 8 | github.com/coder/websocket v1.8.15 | ||
| 8 | github.com/quic-go/quic-go v0.62.0 | 9 | github.com/quic-go/quic-go v0.62.0 | ||
M internal/adapters/config/config.go +74 -106
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bufio" | |
| 4 | + | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| @@ -12,13 +12,11 @@ import ( | |||
| 12 | 12 | "strings" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/kvconf" | |
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 17 | - | "github.com/bnema/vev/internal/ports" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | - | const pollInterval = 2 * time.Second | |
| 21 | - | ||
| 22 | 20 | var ( | |
| 23 | 21 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |
| 24 | 22 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | |
| @@ -43,30 +41,19 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 43 | 41 | seenScrollbackKeys := make(map[string]bool) | |
| 44 | 42 | seenWebKeys := make(map[string]bool) | |
| 45 | 43 | ||
| 46 | - | scanner := bufio.NewScanner(r) | |
| 47 | - | lineNo := 0 | |
| 48 | - | for scanner.Scan() { | |
| 49 | - | lineNo++ | |
| 50 | - | line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r")) | |
| 51 | - | if line == "" || strings.HasPrefix(line, "#") { | |
| 52 | - | continue | |
| 53 | - | } | |
| 54 | - | line = stripInlineComment(line) | |
| 55 | - | if line == "" { | |
| 56 | - | continue | |
| 57 | - | } | |
| 44 | + | entries, syntax, err := kvconf.Parse(r, parseOptions) | |
| 45 | + | if err != nil { | |
| 46 | + | return domain.Defaults(), nil, err | |
| 47 | + | } | |
| 48 | + | pending := syntaxWarnings(syntax) | |
| 58 | 49 | ||
| 59 | - | key, value, ok := strings.Cut(line, "=") | |
| 60 | - | if !ok { | |
| 61 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | |
| 62 | - | continue | |
| 63 | - | } | |
| 64 | - | key = strings.TrimSpace(key) | |
| 65 | - | value = strings.TrimSpace(value) | |
| 66 | - | if key == "" { | |
| 67 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"}) | |
| 68 | - | continue | |
| 50 | + | for _, entry := range entries { | |
| 51 | + | // Interleave line-syntax warnings with domain warnings in file order. | |
| 52 | + | for len(pending) > 0 && pending[0].Line < entry.Line { | |
| 53 | + | warnings = append(warnings, pending[0]) | |
| 54 | + | pending = pending[1:] | |
| 69 | 55 | } | |
| 56 | + | lineNo, key, value := entry.Line, entry.Key, entry.Value | |
| 70 | 57 | ||
| 71 | 58 | switch { | |
| 72 | 59 | case key == "web.listen" || key == "web.origin": | |
| @@ -278,84 +265,92 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 278 | 265 | } | |
| 279 | 266 | } | |
| 280 | 267 | } | |
| 281 | - | if err := scanner.Err(); err != nil { | |
| 282 | - | return cfg, warnings, err | |
| 283 | - | } | |
| 268 | + | warnings = append(warnings, pending...) | |
| 284 | 269 | return cfg, warnings, nil | |
| 285 | 270 | } | |
| 286 | 271 | ||
| 272 | + | // parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes | |
| 273 | + | // per key and reports its own duplicate warnings. | |
| 274 | + | var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true} | |
| 275 | + | ||
| 276 | + | // syntaxWarnings maps kvconf line-syntax warnings to domain warnings. | |
| 277 | + | func syntaxWarnings(in []kvconf.Warning) []domain.Warning { | |
| 278 | + | out := make([]domain.Warning, 0, len(in)) | |
| 279 | + | for _, w := range in { | |
| 280 | + | msg := w.Msg | |
| 281 | + | if msg == "expected key = value" { | |
| 282 | + | msg = "missing '='" | |
| 283 | + | } | |
| 284 | + | out = append(out, domain.Warning{Line: w.Line, Msg: msg}) | |
| 285 | + | } | |
| 286 | + | return out | |
| 287 | + | } | |
| 288 | + | ||
| 287 | 289 | // Load reads path and parses it. Missing config files are treated as defaults. | |
| 288 | 290 | func Load(path string) (domain.Config, []domain.Warning, error) { | |
| 289 | - | f, err := os.Open(path) | |
| 291 | + | data, err := kvconf.ReadFile(path, parseOptions) | |
| 290 | 292 | if errors.Is(err, os.ErrNotExist) { | |
| 291 | 293 | return domain.Defaults(), nil, nil | |
| 292 | 294 | } | |
| 293 | 295 | if err != nil { | |
| 294 | 296 | return domain.Defaults(), nil, err | |
| 295 | 297 | } | |
| 296 | - | cfg, warnings, parseErr := Parse(f) | |
| 297 | - | if closeErr := f.Close(); parseErr == nil && closeErr != nil { | |
| 298 | - | return cfg, warnings, closeErr | |
| 299 | - | } | |
| 300 | - | return cfg, warnings, parseErr | |
| 298 | + | return Parse(bytes.NewReader(data)) | |
| 301 | 299 | } | |
| 302 | 300 | ||
| 303 | - | // Watch polls path for mtime/size changes every two seconds and calls onChange | |
| 304 | - | // with each successfully loaded config. Missing files are allowed and reload to | |
| 305 | - | // defaults when the watched state changes. | |
| 306 | - | func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error { | |
| 307 | - | last, err := fileStamp(path) | |
| 301 | + | // Watch follows path with native file notifications (kvconf.Watch) and calls | |
| 302 | + | // onChange with the config reloaded after each change. Missing files are | |
| 303 | + | // allowed and reload to defaults. A load failure reports defaults plus a | |
| 304 | + | // warning; the same failure is not reported twice in a row. Watch returns | |
| 305 | + | // ctx.Err() when ctx ends. | |
| 306 | + | func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error { | |
| 307 | + | return watch(ctx, path, kvconf.WatchOptions{}, onChange) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error { | |
| 308 | 311 | lastErrMsg := "" | |
| 309 | - | if err != nil { | |
| 310 | - | lastErrMsg = err.Error() | |
| 311 | - | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |
| 312 | + | reportErr := func(err error) { | |
| 313 | + | if msg := err.Error(); msg != lastErrMsg { | |
| 314 | + | lastErrMsg = msg | |
| 315 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: msg}}) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 319 | + | reportErr(err) | |
| 312 | 320 | } | |
| 313 | - | timer := clock.NewTimer(pollInterval) | |
| 314 | - | defer timer.Stop() | |
| 321 | + | ||
| 322 | + | watchCtx, cancel := context.WithCancel(ctx) | |
| 323 | + | defer cancel() | |
| 324 | + | changed := make(chan struct{}, 1) | |
| 325 | + | watchErr := make(chan error, 1) | |
| 326 | + | go func() { watchErr <- kvconf.Watch(watchCtx, path, opts, changed) }() | |
| 315 | 327 | ||
| 316 | 328 | for { | |
| 317 | 329 | select { | |
| 318 | 330 | case <-ctx.Done(): | |
| 331 | + | cancel() | |
| 332 | + | <-watchErr | |
| 319 | 333 | return ctx.Err() | |
| 320 | - | case <-timer.C(): | |
| 321 | - | current, statErr := fileStamp(path) | |
| 322 | - | if statErr != nil { | |
| 323 | - | if statErr.Error() != lastErrMsg { | |
| 324 | - | lastErrMsg = statErr.Error() | |
| 325 | - | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |
| 326 | - | } | |
| 327 | - | } else if current != last || lastErrMsg != "" { | |
| 328 | - | lastErrMsg = "" | |
| 329 | - | last = current | |
| 330 | - | cfg, warnings, err := Load(path) | |
| 331 | - | if err != nil { | |
| 332 | - | cfg = domain.Defaults() | |
| 333 | - | warnings = append(warnings, domain.Warning{Msg: err.Error()}) | |
| 334 | - | } | |
| 335 | - | onChange(cfg, warnings) | |
| 334 | + | case err := <-watchErr: | |
| 335 | + | if ctx.Err() != nil { | |
| 336 | + | return ctx.Err() | |
| 336 | 337 | } | |
| 337 | - | timer.Reset(pollInterval) | |
| 338 | + | if err == nil { | |
| 339 | + | err = errors.New("config watcher stopped") | |
| 340 | + | } | |
| 341 | + | return err | |
| 342 | + | case <-changed: | |
| 343 | + | cfg, warnings, err := Load(path) | |
| 344 | + | if err != nil { | |
| 345 | + | reportErr(err) | |
| 346 | + | continue | |
| 347 | + | } | |
| 348 | + | lastErrMsg = "" | |
| 349 | + | onChange(cfg, warnings) | |
| 338 | 350 | } | |
| 339 | 351 | } | |
| 340 | 352 | } | |
| 341 | 353 | ||
| 342 | - | type stamp struct { | |
| 343 | - | modTime time.Time | |
| 344 | - | size int64 | |
| 345 | - | exists bool | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | func fileStamp(path string) (stamp, error) { | |
| 349 | - | st, err := os.Stat(path) | |
| 350 | - | if errors.Is(err, os.ErrNotExist) { | |
| 351 | - | return stamp{}, nil | |
| 352 | - | } | |
| 353 | - | if err != nil { | |
| 354 | - | return stamp{}, err | |
| 355 | - | } | |
| 356 | - | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | |
| 357 | - | } | |
| 358 | - | ||
| 359 | 354 | func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { | |
| 360 | 355 | switch strings.ToLower(value) { | |
| 361 | 356 | case "adaptive": | |
| @@ -474,30 +469,3 @@ func parseTheme(value string) (domain.ThemeMode, bool) { | |||
| 474 | 469 | return domain.ThemeAuto, false | |
| 475 | 470 | } | |
| 476 | 471 | } | |
| 477 | - | ||
| 478 | - | func stripInlineComment(line string) string { | |
| 479 | - | inQuote := false | |
| 480 | - | escaped := false | |
| 481 | - | for i, r := range line { | |
| 482 | - | if escaped { | |
| 483 | - | escaped = false | |
| 484 | - | continue | |
| 485 | - | } | |
| 486 | - | if inQuote && r == '\\' { | |
| 487 | - | escaped = true | |
| 488 | - | continue | |
| 489 | - | } | |
| 490 | - | if r == '"' { | |
| 491 | - | inQuote = !inQuote | |
| 492 | - | continue | |
| 493 | - | } | |
| 494 | - | if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) { | |
| 495 | - | return strings.TrimSpace(line[:i]) | |
| 496 | - | } | |
| 497 | - | } | |
| 498 | - | return line | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | func isSpace(r rune) bool { | |
| 502 | - | return r == ' ' || r == '\t' || r == '\r' || r == '\n' | |
| 503 | - | } | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bufio" | 4 | + | "bytes" |
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| @@ -12,13 +12,11 @@ import ( | |||||
| 12 | "strings" | 12 | "strings" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/kvconf" | |||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/domain/terminalcap" | 17 | "github.com/bnema/vev/internal/domain/terminalcap" | ||
| 17 | - | "github.com/bnema/vev/internal/ports" | |||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | - | const pollInterval = 2 * time.Second | |||
| 21 | - | ||||
| 22 | var ( | 20 | var ( | ||
| 23 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | 21 | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | ||
| 24 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | 22 | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | ||
| @@ -43,30 +41,19 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 43 | seenScrollbackKeys := make(map[string]bool) | 41 | seenScrollbackKeys := make(map[string]bool) | ||
| 44 | seenWebKeys := make(map[string]bool) | 42 | seenWebKeys := make(map[string]bool) | ||
| 45 | 43 | ||||
| 46 | - | scanner := bufio.NewScanner(r) | 44 | + | entries, syntax, err := kvconf.Parse(r, parseOptions) |
| 47 | - | lineNo := 0 | 45 | + | if err != nil { |
| 48 | - | for scanner.Scan() { | 46 | + | return domain.Defaults(), nil, err |
| 49 | - | lineNo++ | 47 | + | } |
| 50 | - | line := strings.TrimSpace(strings.TrimSuffix(scanner.Text(), "\r")) | 48 | + | pending := syntaxWarnings(syntax) |
| 51 | - | if line == "" || strings.HasPrefix(line, "#") { | |||
| 52 | - | continue | |||
| 53 | - | } | |||
| 54 | - | line = stripInlineComment(line) | |||
| 55 | - | if line == "" { | |||
| 56 | - | continue | |||
| 57 | - | } | |||
| 58 | 49 | ||||
| 59 | - | key, value, ok := strings.Cut(line, "=") | 50 | + | for _, entry := range entries { |
| 60 | - | if !ok { | 51 | + | // Interleave line-syntax warnings with domain warnings in file order. |
| 61 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing '='"}) | 52 | + | for len(pending) > 0 && pending[0].Line < entry.Line { |
| 62 | - | continue | 53 | + | warnings = append(warnings, pending[0]) |
| 63 | - | } | 54 | + | pending = pending[1:] |
| 64 | - | key = strings.TrimSpace(key) | |||
| 65 | - | value = strings.TrimSpace(value) | |||
| 66 | - | if key == "" { | |||
| 67 | - | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "missing key"}) | |||
| 68 | - | continue | |||
| 69 | } | 55 | } | ||
| 56 | + | lineNo, key, value := entry.Line, entry.Key, entry.Value | |||
| 70 | 57 | ||||
| 71 | switch { | 58 | switch { | ||
| 72 | case key == "web.listen" || key == "web.origin": | 59 | case key == "web.listen" || key == "web.origin": | ||
| @@ -278,84 +265,92 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 278 | } | 265 | } | ||
| 279 | } | 266 | } | ||
| 280 | } | 267 | } | ||
| 281 | - | if err := scanner.Err(); err != nil { | 268 | + | warnings = append(warnings, pending...) |
| 282 | - | return cfg, warnings, err | |||
| 283 | - | } | |||
| 284 | return cfg, warnings, nil | 269 | return cfg, warnings, nil | ||
| 285 | } | 270 | } | ||
| 286 | 271 | ||||
| 272 | + | // parseOptions keeps quotes and duplicates in the kvconf entries: vev unquotes | |||
| 273 | + | // per key and reports its own duplicate warnings. | |||
| 274 | + | var parseOptions = kvconf.Options{RawValues: true, AllowDuplicates: true} | |||
| 275 | + | ||||
| 276 | + | // syntaxWarnings maps kvconf line-syntax warnings to domain warnings. | |||
| 277 | + | func syntaxWarnings(in []kvconf.Warning) []domain.Warning { | |||
| 278 | + | out := make([]domain.Warning, 0, len(in)) | |||
| 279 | + | for _, w := range in { | |||
| 280 | + | msg := w.Msg | |||
| 281 | + | if msg == "expected key = value" { | |||
| 282 | + | msg = "missing '='" | |||
| 283 | + | } | |||
| 284 | + | out = append(out, domain.Warning{Line: w.Line, Msg: msg}) | |||
| 285 | + | } | |||
| 286 | + | return out | |||
| 287 | + | } | |||
| 288 | + | ||||
| 287 | // Load reads path and parses it. Missing config files are treated as defaults. | 289 | // Load reads path and parses it. Missing config files are treated as defaults. | ||
| 288 | func Load(path string) (domain.Config, []domain.Warning, error) { | 290 | func Load(path string) (domain.Config, []domain.Warning, error) { | ||
| 289 | - | f, err := os.Open(path) | 291 | + | data, err := kvconf.ReadFile(path, parseOptions) |
| 290 | if errors.Is(err, os.ErrNotExist) { | 292 | if errors.Is(err, os.ErrNotExist) { | ||
| 291 | return domain.Defaults(), nil, nil | 293 | return domain.Defaults(), nil, nil | ||
| 292 | } | 294 | } | ||
| 293 | if err != nil { | 295 | if err != nil { | ||
| 294 | return domain.Defaults(), nil, err | 296 | return domain.Defaults(), nil, err | ||
| 295 | } | 297 | } | ||
| 296 | - | cfg, warnings, parseErr := Parse(f) | 298 | + | return Parse(bytes.NewReader(data)) |
| 297 | - | if closeErr := f.Close(); parseErr == nil && closeErr != nil { | |||
| 298 | - | return cfg, warnings, closeErr | |||
| 299 | - | } | |||
| 300 | - | return cfg, warnings, parseErr | |||
| 301 | } | 299 | } | ||
| 302 | 300 | ||||
| 303 | - | // Watch polls path for mtime/size changes every two seconds and calls onChange | 301 | + | // Watch follows path with native file notifications (kvconf.Watch) and calls |
| 304 | - | // with each successfully loaded config. Missing files are allowed and reload to | 302 | + | // onChange with the config reloaded after each change. Missing files are |
| 305 | - | // defaults when the watched state changes. | 303 | + | // allowed and reload to defaults. A load failure reports defaults plus a |
| 306 | - | func Watch(ctx context.Context, clock ports.Clock, path string, onChange func(domain.Config, []domain.Warning)) error { | 304 | + | // warning; the same failure is not reported twice in a row. Watch returns |
| 307 | - | last, err := fileStamp(path) | 305 | + | // ctx.Err() when ctx ends. |
| 306 | + | func Watch(ctx context.Context, path string, onChange func(domain.Config, []domain.Warning)) error { | |||
| 307 | + | return watch(ctx, path, kvconf.WatchOptions{}, onChange) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func watch(ctx context.Context, path string, opts kvconf.WatchOptions, onChange func(domain.Config, []domain.Warning)) error { | |||
| 308 | lastErrMsg := "" | 311 | lastErrMsg := "" | ||
| 309 | - | if err != nil { | 312 | + | reportErr := func(err error) { |
| 310 | - | lastErrMsg = err.Error() | 313 | + | if msg := err.Error(); msg != lastErrMsg { |
| 311 | - | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | 314 | + | lastErrMsg = msg |
| 315 | + | onChange(domain.Defaults(), []domain.Warning{{Msg: msg}}) | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 319 | + | reportErr(err) | |||
| 312 | } | 320 | } | ||
| 313 | - | timer := clock.NewTimer(pollInterval) | 321 | + | |
| 314 | - | defer timer.Stop() | 322 | + | watchCtx, cancel := context.WithCancel(ctx) |
| 323 | + | defer cancel() | |||
| 324 | + | changed := make(chan struct{}, 1) | |||
| 325 | + | watchErr := make(chan error, 1) | |||
| 326 | + | go func() { watchErr <- kvconf.Watch(watchCtx, path, opts, changed) }() | |||
| 315 | 327 | ||||
| 316 | for { | 328 | for { | ||
| 317 | select { | 329 | select { | ||
| 318 | case <-ctx.Done(): | 330 | case <-ctx.Done(): | ||
| 331 | + | cancel() | |||
| 332 | + | <-watchErr | |||
| 319 | return ctx.Err() | 333 | return ctx.Err() | ||
| 320 | - | case <-timer.C(): | 334 | + | case err := <-watchErr: |
| 321 | - | current, statErr := fileStamp(path) | 335 | + | if ctx.Err() != nil { |
| 322 | - | if statErr != nil { | 336 | + | return ctx.Err() |
| 323 | - | if statErr.Error() != lastErrMsg { | |||
| 324 | - | lastErrMsg = statErr.Error() | |||
| 325 | - | onChange(domain.Defaults(), []domain.Warning{{Msg: lastErrMsg}}) | |||
| 326 | - | } | |||
| 327 | - | } else if current != last || lastErrMsg != "" { | |||
| 328 | - | lastErrMsg = "" | |||
| 329 | - | last = current | |||
| 330 | - | cfg, warnings, err := Load(path) | |||
| 331 | - | if err != nil { | |||
| 332 | - | cfg = domain.Defaults() | |||
| 333 | - | warnings = append(warnings, domain.Warning{Msg: err.Error()}) | |||
| 334 | - | } | |||
| 335 | - | onChange(cfg, warnings) | |||
| 336 | } | 337 | } | ||
| 337 | - | timer.Reset(pollInterval) | 338 | + | if err == nil { |
| 339 | + | err = errors.New("config watcher stopped") | |||
| 340 | + | } | |||
| 341 | + | return err | |||
| 342 | + | case <-changed: | |||
| 343 | + | cfg, warnings, err := Load(path) | |||
| 344 | + | if err != nil { | |||
| 345 | + | reportErr(err) | |||
| 346 | + | continue | |||
| 347 | + | } | |||
| 348 | + | lastErrMsg = "" | |||
| 349 | + | onChange(cfg, warnings) | |||
| 338 | } | 350 | } | ||
| 339 | } | 351 | } | ||
| 340 | } | 352 | } | ||
| 341 | 353 | ||||
| 342 | - | type stamp struct { | |||
| 343 | - | modTime time.Time | |||
| 344 | - | size int64 | |||
| 345 | - | exists bool | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | func fileStamp(path string) (stamp, error) { | |||
| 349 | - | st, err := os.Stat(path) | |||
| 350 | - | if errors.Is(err, os.ErrNotExist) { | |||
| 351 | - | return stamp{}, nil | |||
| 352 | - | } | |||
| 353 | - | if err != nil { | |||
| 354 | - | return stamp{}, err | |||
| 355 | - | } | |||
| 356 | - | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | |||
| 357 | - | } | |||
| 358 | - | ||||
| 359 | func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { | 354 | func parseEchoPredict(value string) (domain.EchoPredictMode, bool) { | ||
| 360 | switch strings.ToLower(value) { | 355 | switch strings.ToLower(value) { | ||
| 361 | case "adaptive": | 356 | case "adaptive": | ||
| @@ -474,30 +469,3 @@ func parseTheme(value string) (domain.ThemeMode, bool) { | |||||
| 474 | return domain.ThemeAuto, false | 469 | return domain.ThemeAuto, false | ||
| 475 | } | 470 | } | ||
| 476 | } | 471 | } | ||
| 477 | - | ||||
| 478 | - | func stripInlineComment(line string) string { | |||
| 479 | - | inQuote := false | |||
| 480 | - | escaped := false | |||
| 481 | - | for i, r := range line { | |||
| 482 | - | if escaped { | |||
| 483 | - | escaped = false | |||
| 484 | - | continue | |||
| 485 | - | } | |||
| 486 | - | if inQuote && r == '\\' { | |||
| 487 | - | escaped = true | |||
| 488 | - | continue | |||
| 489 | - | } | |||
| 490 | - | if r == '"' { | |||
| 491 | - | inQuote = !inQuote | |||
| 492 | - | continue | |||
| 493 | - | } | |||
| 494 | - | if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) { | |||
| 495 | - | return strings.TrimSpace(line[:i]) | |||
| 496 | - | } | |||
| 497 | - | } | |||
| 498 | - | return line | |||
| 499 | - | } | |||
| 500 | - | ||||
| 501 | - | func isSpace(r rune) bool { | |||
| 502 | - | return r == ' ' || r == '\t' || r == '\r' || r == '\n' | |||
| 503 | - | } | |||
M internal/adapters/config/config_test.go +140 -136
| @@ -2,7 +2,6 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "errors" | |
| 6 | 5 | "os" | |
| 7 | 6 | "path/filepath" | |
| 8 | 7 | "reflect" | |
| @@ -10,8 +9,8 @@ import ( | |||
| 10 | 9 | "testing" | |
| 11 | 10 | "time" | |
| 12 | 11 | ||
| 12 | + | "github.com/bnema/kvconf" | |
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 14 | "github.com/stretchr/testify/require" | |
| 16 | 15 | ) | |
| 17 | 16 | ||
| @@ -833,179 +832,184 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) { | |||
| 833 | 832 | } | |
| 834 | 833 | } | |
| 835 | 834 | ||
| 836 | - | func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | |
| 837 | - | t.Parallel() | |
| 838 | - | ||
| 839 | - | path := filepath.Join(t.TempDir(), "config") | |
| 840 | - | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | |
| 841 | - | t.Fatal(err) | |
| 842 | - | } | |
| 835 | + | type watchChange struct { | |
| 836 | + | cfg domain.Config | |
| 837 | + | warnings []domain.Warning | |
| 838 | + | } | |
| 843 | 839 | ||
| 844 | - | clk := newWatchClockMock(t) | |
| 840 | + | // startWatch runs watch on path and returns the onChange stream once the | |
| 841 | + | // watches are installed. The returned stop cancels it and checks its result. | |
| 842 | + | func startWatch(t *testing.T, path string) (<-chan watchChange, func()) { | |
| 843 | + | t.Helper() | |
| 845 | 844 | ctx, cancel := context.WithCancel(context.Background()) | |
| 846 | - | defer cancel() | |
| 845 | + | t.Cleanup(cancel) | |
| 847 | 846 | ||
| 848 | - | type change struct { | |
| 849 | - | cfg domain.Config | |
| 850 | - | warnings []domain.Warning | |
| 851 | - | } | |
| 852 | - | changes := make(chan change, 2) | |
| 847 | + | changes := make(chan watchChange, 8) | |
| 848 | + | ready := make(chan struct{}) | |
| 853 | 849 | done := make(chan error, 1) | |
| 854 | 850 | go func() { | |
| 855 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 856 | - | changes <- change{cfg: cfg, warnings: warnings} | |
| 857 | - | }) | |
| 851 | + | done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready}, | |
| 852 | + | func(cfg domain.Config, warnings []domain.Warning) { | |
| 853 | + | changes <- watchChange{cfg: cfg, warnings: warnings} | |
| 854 | + | }) | |
| 858 | 855 | }() | |
| 859 | - | ||
| 860 | - | clk.waitForTimer() | |
| 861 | - | if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil { | |
| 862 | - | t.Fatal(err) | |
| 856 | + | select { | |
| 857 | + | case <-ready: | |
| 858 | + | case err := <-done: | |
| 859 | + | t.Fatalf("watch() returned before ready: %v", err) | |
| 860 | + | case <-time.After(5 * time.Second): | |
| 861 | + | t.Fatal("timed out waiting for the watcher to be ready") | |
| 862 | + | } | |
| 863 | + | return changes, func() { | |
| 864 | + | t.Helper() | |
| 865 | + | cancel() | |
| 866 | + | select { | |
| 867 | + | case err := <-done: | |
| 868 | + | require.ErrorIs(t, err, context.Canceled) | |
| 869 | + | case <-time.After(5 * time.Second): | |
| 870 | + | t.Fatal("timed out waiting for watch() to return") | |
| 871 | + | } | |
| 863 | 872 | } | |
| 864 | - | clk.fire() | |
| 873 | + | } | |
| 865 | 874 | ||
| 866 | - | var got change | |
| 875 | + | func nextChange(t *testing.T, changes <-chan watchChange) watchChange { | |
| 876 | + | t.Helper() | |
| 867 | 877 | select { | |
| 868 | - | case got = <-changes: | |
| 869 | - | case <-time.After(2 * time.Second): | |
| 878 | + | case got := <-changes: | |
| 879 | + | return got | |
| 880 | + | case <-time.After(5 * time.Second): | |
| 870 | 881 | t.Fatal("timed out waiting for config reload") | |
| 871 | - | } | |
| 872 | - | if got.cfg.Theme != domain.ThemeLight { | |
| 873 | - | t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight) | |
| 874 | - | } | |
| 875 | - | if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) { | |
| 876 | - | t.Fatalf("Watch() warnings = %#v", got.warnings) | |
| 877 | - | } | |
| 878 | - | ||
| 879 | - | cancel() | |
| 880 | - | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 881 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |
| 882 | + | return watchChange{} | |
| 882 | 883 | } | |
| 883 | 884 | } | |
| 884 | 885 | ||
| 885 | - | func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) { | |
| 886 | + | func TestWatchReloadsOnChange(t *testing.T) { | |
| 886 | 887 | t.Parallel() | |
| 887 | 888 | ||
| 888 | - | path := filepath.Join(t.TempDir(), "config") | |
| 889 | - | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | |
| 890 | - | t.Fatal(err) | |
| 891 | - | } | |
| 892 | - | ||
| 893 | - | clk := newWatchClockMock(t) | |
| 894 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 895 | - | defer cancel() | |
| 896 | - | ||
| 897 | - | type change struct { | |
| 898 | - | cfg domain.Config | |
| 899 | - | warnings []domain.Warning | |
| 900 | - | } | |
| 901 | - | changes := make(chan change, 2) | |
| 902 | - | done := make(chan error, 1) | |
| 903 | - | go func() { | |
| 904 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 905 | - | changes <- change{cfg: cfg, warnings: warnings} | |
| 906 | - | }) | |
| 907 | - | }() | |
| 889 | + | light := domain.Defaults() | |
| 890 | + | light.Theme = domain.ThemeLight | |
| 908 | 891 | ||
| 909 | - | clk.waitForTimer() | |
| 910 | - | tooLong := strings.Repeat("x", 70*1024) | |
| 911 | - | if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil { | |
| 912 | - | t.Fatal(err) | |
| 892 | + | tests := []struct { | |
| 893 | + | name string | |
| 894 | + | initial *string // nil: the file does not exist yet | |
| 895 | + | write func(t *testing.T, path string) | |
| 896 | + | wantConfig domain.Config | |
| 897 | + | wantWarnings []domain.Warning | |
| 898 | + | wantAnyWarn bool | |
| 899 | + | }{ | |
| 900 | + | { | |
| 901 | + | name: "changed file reloads with parse warnings", | |
| 902 | + | initial: ptr("theme = dark\n"), | |
| 903 | + | write: func(t *testing.T, path string) { | |
| 904 | + | require.NoError(t, os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600)) | |
| 905 | + | }, | |
| 906 | + | wantConfig: light, | |
| 907 | + | wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}}, | |
| 908 | + | }, | |
| 909 | + | { | |
| 910 | + | name: "created file loads", | |
| 911 | + | initial: nil, | |
| 912 | + | write: func(t *testing.T, path string) { | |
| 913 | + | require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600)) | |
| 914 | + | }, | |
| 915 | + | wantConfig: light, | |
| 916 | + | }, | |
| 917 | + | { | |
| 918 | + | name: "deleted file reloads to defaults", | |
| 919 | + | initial: ptr("theme = light\n"), | |
| 920 | + | write: func(t *testing.T, path string) { | |
| 921 | + | require.NoError(t, os.Remove(path)) | |
| 922 | + | }, | |
| 923 | + | wantConfig: domain.Defaults(), | |
| 924 | + | }, | |
| 925 | + | { | |
| 926 | + | name: "load failure reports defaults and a warning", | |
| 927 | + | initial: ptr("theme = light\n"), | |
| 928 | + | write: func(t *testing.T, path string) { | |
| 929 | + | // Larger than kvconf.DefaultMaxBytes. | |
| 930 | + | require.NoError(t, os.WriteFile(path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)), 0o600)) | |
| 931 | + | }, | |
| 932 | + | wantConfig: domain.Defaults(), | |
| 933 | + | wantAnyWarn: true, | |
| 934 | + | }, | |
| 935 | + | { | |
| 936 | + | name: "invalid UTF-8 reports defaults and a warning", | |
| 937 | + | initial: ptr("theme = light\n"), | |
| 938 | + | write: func(t *testing.T, path string) { | |
| 939 | + | require.NoError(t, os.WriteFile(path, []byte("theme = \xff\n"), 0o600)) | |
| 940 | + | }, | |
| 941 | + | wantConfig: domain.Defaults(), | |
| 942 | + | wantAnyWarn: true, | |
| 943 | + | }, | |
| 913 | 944 | } | |
| 914 | - | clk.fire() | |
| 915 | - | ||
| 916 | - | got := <-changes | |
| 917 | - | require.Equal(t, domain.Defaults(), got.cfg) | |
| 918 | - | require.NotEmpty(t, got.warnings) | |
| 945 | + | for _, tt := range tests { | |
| 946 | + | t.Run(tt.name, func(t *testing.T) { | |
| 947 | + | t.Parallel() | |
| 919 | 948 | ||
| 920 | - | cancel() | |
| 921 | - | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 922 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |
| 949 | + | path := filepath.Join(t.TempDir(), "config") | |
| 950 | + | if tt.initial != nil { | |
| 951 | + | require.NoError(t, os.WriteFile(path, []byte(*tt.initial), 0o600)) | |
| 952 | + | } | |
| 953 | + | changes, stop := startWatch(t, path) | |
| 954 | + | tt.write(t, path) | |
| 955 | + | ||
| 956 | + | got := nextChange(t, changes) | |
| 957 | + | require.Equal(t, tt.wantConfig, got.cfg) | |
| 958 | + | if tt.wantAnyWarn { | |
| 959 | + | require.NotEmpty(t, got.warnings) | |
| 960 | + | } else { | |
| 961 | + | require.Equal(t, tt.wantWarnings, got.warnings) | |
| 962 | + | } | |
| 963 | + | stop() | |
| 964 | + | }) | |
| 923 | 965 | } | |
| 924 | 966 | } | |
| 925 | 967 | ||
| 926 | - | func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | |
| 968 | + | func TestWatchReportsStatErrorOnce(t *testing.T) { | |
| 927 | 969 | t.Parallel() | |
| 928 | 970 | ||
| 971 | + | // A name longer than NAME_MAX makes every stat fail with the same error. | |
| 929 | 972 | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | |
| 930 | - | clk := newWatchClockMock(t) | |
| 931 | 973 | ctx, cancel := context.WithCancel(context.Background()) | |
| 932 | 974 | defer cancel() | |
| 933 | 975 | ||
| 934 | 976 | changes := make(chan []domain.Warning, 4) | |
| 977 | + | ready := make(chan struct{}) | |
| 935 | 978 | done := make(chan error, 1) | |
| 936 | 979 | go func() { | |
| 937 | - | done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) { | |
| 938 | - | changes <- warnings | |
| 939 | - | }) | |
| 980 | + | done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready}, | |
| 981 | + | func(cfg domain.Config, warnings []domain.Warning) { | |
| 982 | + | require.Equal(t, domain.Defaults(), cfg) | |
| 983 | + | changes <- warnings | |
| 984 | + | }) | |
| 940 | 985 | }() | |
| 941 | 986 | ||
| 942 | - | first := <-changes | |
| 943 | - | require.Len(t, first, 1) | |
| 944 | - | clk.waitForTimer() | |
| 945 | - | clk.fire() | |
| 946 | - | clk.fire() | |
| 947 | - | ||
| 948 | 987 | select { | |
| 949 | - | case extra := <-changes: | |
| 950 | - | t.Fatalf("Watch() repeated persistent stat error: %#v", extra) | |
| 951 | - | case <-time.After(100 * time.Millisecond): | |
| 988 | + | case first := <-changes: | |
| 989 | + | require.Len(t, first, 1) | |
| 990 | + | case <-time.After(5 * time.Second): | |
| 991 | + | t.Fatal("timed out waiting for the initial stat error") | |
| 952 | 992 | } | |
| 953 | - | ||
| 954 | - | cancel() | |
| 955 | - | if err := <-done; !errors.Is(err, context.Canceled) { | |
| 956 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |
| 957 | - | } | |
| 958 | - | } | |
| 959 | - | ||
| 960 | - | type watchClockMock struct { | |
| 961 | - | t *testing.T | |
| 962 | - | clock *portsmocks.MockClock | |
| 963 | - | ticks chan time.Time | |
| 964 | - | ready chan struct{} | |
| 965 | - | resets chan struct{} | |
| 966 | - | } | |
| 967 | - | ||
| 968 | - | func newWatchClockMock(t *testing.T) *watchClockMock { | |
| 969 | - | t.Helper() | |
| 970 | - | timer := portsmocks.NewMockTimer(t) | |
| 971 | - | clock := portsmocks.NewMockClock(t) | |
| 972 | - | m := &watchClockMock{ | |
| 973 | - | t: t, | |
| 974 | - | clock: clock, | |
| 975 | - | ticks: make(chan time.Time), | |
| 976 | - | ready: make(chan struct{}), | |
| 977 | - | resets: make(chan struct{}), | |
| 978 | - | } | |
| 979 | - | timer.EXPECT().C().Return(m.ticks) | |
| 980 | - | timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) { | |
| 981 | - | m.resets <- struct{}{} | |
| 982 | - | }).Return(false) | |
| 983 | - | timer.EXPECT().Stop().Return(true) | |
| 984 | - | clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) { | |
| 985 | - | close(m.ready) | |
| 986 | - | }).Return(timer).Once() | |
| 987 | - | return m | |
| 988 | - | } | |
| 989 | - | ||
| 990 | - | func (m *watchClockMock) waitForTimer() { | |
| 991 | - | m.t.Helper() | |
| 992 | 993 | select { | |
| 993 | - | case <-m.ready: | |
| 994 | - | case <-time.After(time.Second): | |
| 995 | - | m.t.Fatal("timed out waiting for watcher timer creation") | |
| 994 | + | case extra := <-changes: | |
| 995 | + | t.Fatalf("watch() repeated persistent stat error: %#v", extra) | |
| 996 | + | case <-done: | |
| 997 | + | // The watcher may refuse such a path; the error is still reported once. | |
| 998 | + | case <-time.After(300 * time.Millisecond): | |
| 996 | 999 | } | |
| 997 | - | } | |
| 998 | 1000 | ||
| 999 | - | func (m *watchClockMock) fire() { | |
| 1000 | - | m.t.Helper() | |
| 1001 | + | cancel() | |
| 1001 | 1002 | select { | |
| 1002 | - | case m.ticks <- time.Now(): | |
| 1003 | - | case <-time.After(time.Second): | |
| 1004 | - | m.t.Fatal("timed out firing watcher timer") | |
| 1003 | + | case err := <-done: | |
| 1004 | + | _ = err | |
| 1005 | + | case <-time.After(5 * time.Second): | |
| 1006 | + | t.Fatal("timed out waiting for watch() to return") | |
| 1005 | 1007 | } | |
| 1006 | 1008 | select { | |
| 1007 | - | case <-m.resets: | |
| 1008 | - | case <-time.After(time.Second): | |
| 1009 | - | m.t.Fatal("timed out waiting for watcher timer reset") | |
| 1009 | + | case extra := <-changes: | |
| 1010 | + | t.Fatalf("watch() repeated persistent stat error: %#v", extra) | |
| 1011 | + | default: | |
| 1010 | 1012 | } | |
| 1011 | 1013 | } | |
| 1014 | + | ||
| 1015 | + | func ptr[T any](v T) *T { return &v } | |
| @@ -2,7 +2,6 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "errors" | |||
| 6 | "os" | 5 | "os" | ||
| 7 | "path/filepath" | 6 | "path/filepath" | ||
| 8 | "reflect" | 7 | "reflect" | ||
| @@ -10,8 +9,8 @@ import ( | |||||
| 10 | "testing" | 9 | "testing" | ||
| 11 | "time" | 10 | "time" | ||
| 12 | 11 | ||||
| 12 | + | "github.com/bnema/kvconf" | |||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 15 | ) | ||
| 17 | 16 | ||||
| @@ -833,179 +832,184 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) { | |||||
| 833 | } | 832 | } | ||
| 834 | } | 833 | } | ||
| 835 | 834 | ||||
| 836 | - | func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) { | 835 | + | type watchChange struct { |
| 837 | - | t.Parallel() | 836 | + | cfg domain.Config |
| 838 | - | 837 | + | warnings []domain.Warning | |
| 839 | - | path := filepath.Join(t.TempDir(), "config") | 838 | + | } |
| 840 | - | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | |||
| 841 | - | t.Fatal(err) | |||
| 842 | - | } | |||
| 843 | 839 | ||||
| 844 | - | clk := newWatchClockMock(t) | 840 | + | // startWatch runs watch on path and returns the onChange stream once the |
| 841 | + | // watches are installed. The returned stop cancels it and checks its result. | |||
| 842 | + | func startWatch(t *testing.T, path string) (<-chan watchChange, func()) { | |||
| 843 | + | t.Helper() | |||
| 845 | ctx, cancel := context.WithCancel(context.Background()) | 844 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 846 | - | defer cancel() | 845 | + | t.Cleanup(cancel) |
| 847 | 846 | ||||
| 848 | - | type change struct { | 847 | + | changes := make(chan watchChange, 8) |
| 849 | - | cfg domain.Config | 848 | + | ready := make(chan struct{}) |
| 850 | - | warnings []domain.Warning | |||
| 851 | - | } | |||
| 852 | - | changes := make(chan change, 2) | |||
| 853 | done := make(chan error, 1) | 849 | done := make(chan error, 1) | ||
| 854 | go func() { | 850 | go func() { | ||
| 855 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | 851 | + | done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready}, |
| 856 | - | changes <- change{cfg: cfg, warnings: warnings} | 852 | + | func(cfg domain.Config, warnings []domain.Warning) { |
| 857 | - | }) | 853 | + | changes <- watchChange{cfg: cfg, warnings: warnings} |
| 854 | + | }) | |||
| 858 | }() | 855 | }() | ||
| 859 | - | 856 | + | select { | |
| 860 | - | clk.waitForTimer() | 857 | + | case <-ready: |
| 861 | - | if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil { | 858 | + | case err := <-done: |
| 862 | - | t.Fatal(err) | 859 | + | t.Fatalf("watch() returned before ready: %v", err) |
| 860 | + | case <-time.After(5 * time.Second): | |||
| 861 | + | t.Fatal("timed out waiting for the watcher to be ready") | |||
| 862 | + | } | |||
| 863 | + | return changes, func() { | |||
| 864 | + | t.Helper() | |||
| 865 | + | cancel() | |||
| 866 | + | select { | |||
| 867 | + | case err := <-done: | |||
| 868 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 869 | + | case <-time.After(5 * time.Second): | |||
| 870 | + | t.Fatal("timed out waiting for watch() to return") | |||
| 871 | + | } | |||
| 863 | } | 872 | } | ||
| 864 | - | clk.fire() | 873 | + | } |
| 865 | 874 | ||||
| 866 | - | var got change | 875 | + | func nextChange(t *testing.T, changes <-chan watchChange) watchChange { |
| 876 | + | t.Helper() | |||
| 867 | select { | 877 | select { | ||
| 868 | - | case got = <-changes: | 878 | + | case got := <-changes: |
| 869 | - | case <-time.After(2 * time.Second): | 879 | + | return got |
| 880 | + | case <-time.After(5 * time.Second): | |||
| 870 | t.Fatal("timed out waiting for config reload") | 881 | t.Fatal("timed out waiting for config reload") | ||
| 871 | - | } | 882 | + | return watchChange{} |
| 872 | - | if got.cfg.Theme != domain.ThemeLight { | |||
| 873 | - | t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight) | |||
| 874 | - | } | |||
| 875 | - | if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) { | |||
| 876 | - | t.Fatalf("Watch() warnings = %#v", got.warnings) | |||
| 877 | - | } | |||
| 878 | - | ||||
| 879 | - | cancel() | |||
| 880 | - | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 881 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |||
| 882 | } | 883 | } | ||
| 883 | } | 884 | } | ||
| 884 | 885 | ||||
| 885 | - | func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) { | 886 | + | func TestWatchReloadsOnChange(t *testing.T) { |
| 886 | t.Parallel() | 887 | t.Parallel() | ||
| 887 | 888 | ||||
| 888 | - | path := filepath.Join(t.TempDir(), "config") | 889 | + | light := domain.Defaults() |
| 889 | - | if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil { | 890 | + | light.Theme = domain.ThemeLight |
| 890 | - | t.Fatal(err) | |||
| 891 | - | } | |||
| 892 | - | ||||
| 893 | - | clk := newWatchClockMock(t) | |||
| 894 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 895 | - | defer cancel() | |||
| 896 | - | ||||
| 897 | - | type change struct { | |||
| 898 | - | cfg domain.Config | |||
| 899 | - | warnings []domain.Warning | |||
| 900 | - | } | |||
| 901 | - | changes := make(chan change, 2) | |||
| 902 | - | done := make(chan error, 1) | |||
| 903 | - | go func() { | |||
| 904 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 905 | - | changes <- change{cfg: cfg, warnings: warnings} | |||
| 906 | - | }) | |||
| 907 | - | }() | |||
| 908 | 891 | ||||
| 909 | - | clk.waitForTimer() | 892 | + | tests := []struct { |
| 910 | - | tooLong := strings.Repeat("x", 70*1024) | 893 | + | name string |
| 911 | - | if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil { | 894 | + | initial *string // nil: the file does not exist yet |
| 912 | - | t.Fatal(err) | 895 | + | write func(t *testing.T, path string) |
| 896 | + | wantConfig domain.Config | |||
| 897 | + | wantWarnings []domain.Warning | |||
| 898 | + | wantAnyWarn bool | |||
| 899 | + | }{ | |||
| 900 | + | { | |||
| 901 | + | name: "changed file reloads with parse warnings", | |||
| 902 | + | initial: ptr("theme = dark\n"), | |||
| 903 | + | write: func(t *testing.T, path string) { | |||
| 904 | + | require.NoError(t, os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600)) | |||
| 905 | + | }, | |||
| 906 | + | wantConfig: light, | |||
| 907 | + | wantWarnings: []domain.Warning{{Line: 2, Msg: "missing '='"}}, | |||
| 908 | + | }, | |||
| 909 | + | { | |||
| 910 | + | name: "created file loads", | |||
| 911 | + | initial: nil, | |||
| 912 | + | write: func(t *testing.T, path string) { | |||
| 913 | + | require.NoError(t, os.WriteFile(path, []byte("theme = light\n"), 0o600)) | |||
| 914 | + | }, | |||
| 915 | + | wantConfig: light, | |||
| 916 | + | }, | |||
| 917 | + | { | |||
| 918 | + | name: "deleted file reloads to defaults", | |||
| 919 | + | initial: ptr("theme = light\n"), | |||
| 920 | + | write: func(t *testing.T, path string) { | |||
| 921 | + | require.NoError(t, os.Remove(path)) | |||
| 922 | + | }, | |||
| 923 | + | wantConfig: domain.Defaults(), | |||
| 924 | + | }, | |||
| 925 | + | { | |||
| 926 | + | name: "load failure reports defaults and a warning", | |||
| 927 | + | initial: ptr("theme = light\n"), | |||
| 928 | + | write: func(t *testing.T, path string) { | |||
| 929 | + | // Larger than kvconf.DefaultMaxBytes. | |||
| 930 | + | require.NoError(t, os.WriteFile(path, []byte(strings.Repeat("# x\n", kvconf.DefaultMaxBytes/4+1)), 0o600)) | |||
| 931 | + | }, | |||
| 932 | + | wantConfig: domain.Defaults(), | |||
| 933 | + | wantAnyWarn: true, | |||
| 934 | + | }, | |||
| 935 | + | { | |||
| 936 | + | name: "invalid UTF-8 reports defaults and a warning", | |||
| 937 | + | initial: ptr("theme = light\n"), | |||
| 938 | + | write: func(t *testing.T, path string) { | |||
| 939 | + | require.NoError(t, os.WriteFile(path, []byte("theme = \xff\n"), 0o600)) | |||
| 940 | + | }, | |||
| 941 | + | wantConfig: domain.Defaults(), | |||
| 942 | + | wantAnyWarn: true, | |||
| 943 | + | }, | |||
| 913 | } | 944 | } | ||
| 914 | - | clk.fire() | 945 | + | for _, tt := range tests { |
| 915 | - | 946 | + | t.Run(tt.name, func(t *testing.T) { | |
| 916 | - | got := <-changes | 947 | + | t.Parallel() |
| 917 | - | require.Equal(t, domain.Defaults(), got.cfg) | |||
| 918 | - | require.NotEmpty(t, got.warnings) | |||
| 919 | 948 | ||||
| 920 | - | cancel() | 949 | + | path := filepath.Join(t.TempDir(), "config") |
| 921 | - | if err := <-done; !errors.Is(err, context.Canceled) { | 950 | + | if tt.initial != nil { |
| 922 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | 951 | + | require.NoError(t, os.WriteFile(path, []byte(*tt.initial), 0o600)) |
| 952 | + | } | |||
| 953 | + | changes, stop := startWatch(t, path) | |||
| 954 | + | tt.write(t, path) | |||
| 955 | + | ||||
| 956 | + | got := nextChange(t, changes) | |||
| 957 | + | require.Equal(t, tt.wantConfig, got.cfg) | |||
| 958 | + | if tt.wantAnyWarn { | |||
| 959 | + | require.NotEmpty(t, got.warnings) | |||
| 960 | + | } else { | |||
| 961 | + | require.Equal(t, tt.wantWarnings, got.warnings) | |||
| 962 | + | } | |||
| 963 | + | stop() | |||
| 964 | + | }) | |||
| 923 | } | 965 | } | ||
| 924 | } | 966 | } | ||
| 925 | 967 | ||||
| 926 | - | func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) { | 968 | + | func TestWatchReportsStatErrorOnce(t *testing.T) { |
| 927 | t.Parallel() | 969 | t.Parallel() | ||
| 928 | 970 | ||||
| 971 | + | // A name longer than NAME_MAX makes every stat fail with the same error. | |||
| 929 | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | 972 | path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000)) | ||
| 930 | - | clk := newWatchClockMock(t) | |||
| 931 | ctx, cancel := context.WithCancel(context.Background()) | 973 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 932 | defer cancel() | 974 | defer cancel() | ||
| 933 | 975 | ||||
| 934 | changes := make(chan []domain.Warning, 4) | 976 | changes := make(chan []domain.Warning, 4) | ||
| 977 | + | ready := make(chan struct{}) | |||
| 935 | done := make(chan error, 1) | 978 | done := make(chan error, 1) | ||
| 936 | go func() { | 979 | go func() { | ||
| 937 | - | done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) { | 980 | + | done <- watch(ctx, path, kvconf.WatchOptions{Debounce: 10 * time.Millisecond, Ready: ready}, |
| 938 | - | changes <- warnings | 981 | + | func(cfg domain.Config, warnings []domain.Warning) { |
| 939 | - | }) | 982 | + | require.Equal(t, domain.Defaults(), cfg) |
| 983 | + | changes <- warnings | |||
| 984 | + | }) | |||
| 940 | }() | 985 | }() | ||
| 941 | 986 | ||||
| 942 | - | first := <-changes | |||
| 943 | - | require.Len(t, first, 1) | |||
| 944 | - | clk.waitForTimer() | |||
| 945 | - | clk.fire() | |||
| 946 | - | clk.fire() | |||
| 947 | - | ||||
| 948 | select { | 987 | select { | ||
| 949 | - | case extra := <-changes: | 988 | + | case first := <-changes: |
| 950 | - | t.Fatalf("Watch() repeated persistent stat error: %#v", extra) | 989 | + | require.Len(t, first, 1) |
| 951 | - | case <-time.After(100 * time.Millisecond): | 990 | + | case <-time.After(5 * time.Second): |
| 991 | + | t.Fatal("timed out waiting for the initial stat error") | |||
| 952 | } | 992 | } | ||
| 953 | - | ||||
| 954 | - | cancel() | |||
| 955 | - | if err := <-done; !errors.Is(err, context.Canceled) { | |||
| 956 | - | t.Fatalf("Watch() error = %v, want context.Canceled", err) | |||
| 957 | - | } | |||
| 958 | - | } | |||
| 959 | - | ||||
| 960 | - | type watchClockMock struct { | |||
| 961 | - | t *testing.T | |||
| 962 | - | clock *portsmocks.MockClock | |||
| 963 | - | ticks chan time.Time | |||
| 964 | - | ready chan struct{} | |||
| 965 | - | resets chan struct{} | |||
| 966 | - | } | |||
| 967 | - | ||||
| 968 | - | func newWatchClockMock(t *testing.T) *watchClockMock { | |||
| 969 | - | t.Helper() | |||
| 970 | - | timer := portsmocks.NewMockTimer(t) | |||
| 971 | - | clock := portsmocks.NewMockClock(t) | |||
| 972 | - | m := &watchClockMock{ | |||
| 973 | - | t: t, | |||
| 974 | - | clock: clock, | |||
| 975 | - | ticks: make(chan time.Time), | |||
| 976 | - | ready: make(chan struct{}), | |||
| 977 | - | resets: make(chan struct{}), | |||
| 978 | - | } | |||
| 979 | - | timer.EXPECT().C().Return(m.ticks) | |||
| 980 | - | timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) { | |||
| 981 | - | m.resets <- struct{}{} | |||
| 982 | - | }).Return(false) | |||
| 983 | - | timer.EXPECT().Stop().Return(true) | |||
| 984 | - | clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) { | |||
| 985 | - | close(m.ready) | |||
| 986 | - | }).Return(timer).Once() | |||
| 987 | - | return m | |||
| 988 | - | } | |||
| 989 | - | ||||
| 990 | - | func (m *watchClockMock) waitForTimer() { | |||
| 991 | - | m.t.Helper() | |||
| 992 | select { | 993 | select { | ||
| 993 | - | case <-m.ready: | 994 | + | case extra := <-changes: |
| 994 | - | case <-time.After(time.Second): | 995 | + | t.Fatalf("watch() repeated persistent stat error: %#v", extra) |
| 995 | - | m.t.Fatal("timed out waiting for watcher timer creation") | 996 | + | case <-done: |
| 997 | + | // The watcher may refuse such a path; the error is still reported once. | |||
| 998 | + | case <-time.After(300 * time.Millisecond): | |||
| 996 | } | 999 | } | ||
| 997 | - | } | |||
| 998 | 1000 | ||||
| 999 | - | func (m *watchClockMock) fire() { | 1001 | + | cancel() |
| 1000 | - | m.t.Helper() | |||
| 1001 | select { | 1002 | select { | ||
| 1002 | - | case m.ticks <- time.Now(): | 1003 | + | case err := <-done: |
| 1003 | - | case <-time.After(time.Second): | 1004 | + | _ = err |
| 1004 | - | m.t.Fatal("timed out firing watcher timer") | 1005 | + | case <-time.After(5 * time.Second): |
| 1006 | + | t.Fatal("timed out waiting for watch() to return") | |||
| 1005 | } | 1007 | } | ||
| 1006 | select { | 1008 | select { | ||
| 1007 | - | case <-m.resets: | 1009 | + | case extra := <-changes: |
| 1008 | - | case <-time.After(time.Second): | 1010 | + | t.Fatalf("watch() repeated persistent stat error: %#v", extra) |
| 1009 | - | m.t.Fatal("timed out waiting for watcher timer reset") | 1011 | + | default: |
| 1010 | } | 1012 | } | ||
| 1011 | } | 1013 | } | ||
| 1014 | + | ||||
| 1015 | + | func ptr[T any](v T) *T { return &v } | |||
M internal/adapters/config/palette_anchor_test.go +7 -17
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "os" | |
| 6 | 5 | "path/filepath" | |
| 7 | 6 | "strings" | |
| @@ -47,26 +46,17 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) { | |||
| 47 | 46 | } | |
| 48 | 47 | ||
| 49 | 48 | func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) { | |
| 49 | + | t.Parallel() | |
| 50 | + | ||
| 50 | 51 | path := filepath.Join(t.TempDir(), "config") | |
| 51 | 52 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600)) | |
| 52 | - | clk := newWatchClockMock(t) | |
| 53 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | - | defer cancel() | |
| 55 | - | changes := make(chan domain.Config, 2) | |
| 56 | - | done := make(chan error, 1) | |
| 57 | - | go func() { | |
| 58 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) | |
| 59 | - | }() | |
| 60 | - | clk.waitForTimer() | |
| 53 | + | changes, stop := startWatch(t, path) | |
| 61 | 54 | ||
| 62 | 55 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600)) | |
| 63 | - | clk.fire() | |
| 64 | - | require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor) | |
| 56 | + | require.Equal(t, domain.AnchorBottomLeft, nextChange(t, changes).cfg.Palette.Anchor) | |
| 65 | 57 | ||
| 66 | 58 | require.NoError(t, os.Remove(path)) | |
| 67 | - | clk.fire() | |
| 68 | - | got := <-changes | |
| 69 | - | require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette) | |
| 70 | - | cancel() | |
| 71 | - | require.ErrorIs(t, <-done, context.Canceled) | |
| 59 | + | got := nextChange(t, changes) | |
| 60 | + | require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.cfg.Palette) | |
| 61 | + | stop() | |
| 72 | 62 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "os" | 4 | "os" | ||
| 6 | "path/filepath" | 5 | "path/filepath" | ||
| 7 | "strings" | 6 | "strings" | ||
| @@ -47,26 +46,17 @@ func TestLoadMissingPaletteAnchorDefaultsToCenter(t *testing.T) { | |||||
| 47 | } | 46 | } | ||
| 48 | 47 | ||||
| 49 | func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) { | 48 | func TestWatchPaletteAnchorRestoresCenteredDefaultAfterFileRemoval(t *testing.T) { | ||
| 49 | + | t.Parallel() | |||
| 50 | + | ||||
| 50 | path := filepath.Join(t.TempDir(), "config") | 51 | path := filepath.Join(t.TempDir(), "config") | ||
| 51 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600)) | 52 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600)) | ||
| 52 | - | clk := newWatchClockMock(t) | 53 | + | changes, stop := startWatch(t, path) |
| 53 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 54 | - | defer cancel() | |||
| 55 | - | changes := make(chan domain.Config, 2) | |||
| 56 | - | done := make(chan error, 1) | |||
| 57 | - | go func() { | |||
| 58 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) | |||
| 59 | - | }() | |||
| 60 | - | clk.waitForTimer() | |||
| 61 | 54 | ||||
| 62 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600)) | 55 | require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600)) | ||
| 63 | - | clk.fire() | 56 | + | require.Equal(t, domain.AnchorBottomLeft, nextChange(t, changes).cfg.Palette.Anchor) |
| 64 | - | require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor) | |||
| 65 | 57 | ||||
| 66 | require.NoError(t, os.Remove(path)) | 58 | require.NoError(t, os.Remove(path)) | ||
| 67 | - | clk.fire() | 59 | + | got := nextChange(t, changes) |
| 68 | - | got := <-changes | 60 | + | require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.cfg.Palette) |
| 69 | - | require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorCenter, AnchorSet: true}, got.Palette) | 61 | + | stop() |
| 70 | - | cancel() | |||
| 71 | - | require.ErrorIs(t, <-done, context.Canceled) | |||
| 72 | } | 62 | } | ||
M internal/adapters/config/theme_accent_test.go +5 -35
| @@ -1,13 +1,10 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | 4 | "os" | |
| 7 | 5 | "path/filepath" | |
| 8 | 6 | "strings" | |
| 9 | 7 | "testing" | |
| 10 | - | "time" | |
| 11 | 8 | ||
| 12 | 9 | "github.com/bnema/vev/internal/domain" | |
| 13 | 10 | "github.com/stretchr/testify/require" | |
| @@ -118,38 +115,11 @@ func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) { | |||
| 118 | 115 | require.Empty(t, warnings) | |
| 119 | 116 | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent) | |
| 120 | 117 | ||
| 121 | - | clk := newWatchClockMock(t) | |
| 122 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 123 | - | defer cancel() | |
| 124 | - | type change struct { | |
| 125 | - | cfg domain.Config | |
| 126 | - | warnings []domain.Warning | |
| 127 | - | } | |
| 128 | - | changes := make(chan change, 1) | |
| 129 | - | done := make(chan error, 1) | |
| 130 | - | go func() { | |
| 131 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |
| 132 | - | changes <- change{cfg: cfg, warnings: warnings} | |
| 133 | - | }) | |
| 134 | - | }() | |
| 135 | - | ||
| 136 | - | clk.waitForTimer() | |
| 118 | + | changes, stop := startWatch(t, path) | |
| 137 | 119 | require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600)) | |
| 138 | - | clk.fire() | |
| 139 | 120 | ||
| 140 | - | select { | |
| 141 | - | case got := <-changes: | |
| 142 | - | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent) | |
| 143 | - | require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings) | |
| 144 | - | case <-time.After(2 * time.Second): | |
| 145 | - | t.Fatal("timed out waiting for config reload") | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | cancel() | |
| 149 | - | select { | |
| 150 | - | case err := <-done: | |
| 151 | - | require.True(t, errors.Is(err, context.Canceled)) | |
| 152 | - | case <-time.After(2 * time.Second): | |
| 153 | - | t.Fatal("timed out waiting for config watcher shutdown") | |
| 154 | - | } | |
| 121 | + | got := nextChange(t, changes) | |
| 122 | + | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent) | |
| 123 | + | require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings) | |
| 124 | + | stop() | |
| 155 | 125 | } | |
| @@ -1,13 +1,10 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | "os" | 4 | "os" | ||
| 7 | "path/filepath" | 5 | "path/filepath" | ||
| 8 | "strings" | 6 | "strings" | ||
| 9 | "testing" | 7 | "testing" | ||
| 10 | - | "time" | |||
| 11 | 8 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -118,38 +115,11 @@ func TestWatchThemeAccentInvalidReloadFallsBackToAuto(t *testing.T) { | |||||
| 118 | require.Empty(t, warnings) | 115 | require.Empty(t, warnings) | ||
| 119 | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent) | 116 | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}, initial.ThemeAccent) | ||
| 120 | 117 | ||||
| 121 | - | clk := newWatchClockMock(t) | 118 | + | changes, stop := startWatch(t, path) |
| 122 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 123 | - | defer cancel() | |||
| 124 | - | type change struct { | |||
| 125 | - | cfg domain.Config | |||
| 126 | - | warnings []domain.Warning | |||
| 127 | - | } | |||
| 128 | - | changes := make(chan change, 1) | |||
| 129 | - | done := make(chan error, 1) | |||
| 130 | - | go func() { | |||
| 131 | - | done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) { | |||
| 132 | - | changes <- change{cfg: cfg, warnings: warnings} | |||
| 133 | - | }) | |||
| 134 | - | }() | |||
| 135 | - | ||||
| 136 | - | clk.waitForTimer() | |||
| 137 | require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600)) | 119 | require.NoError(t, os.WriteFile(path, []byte("theme.accent = invalid\n"), 0o600)) | ||
| 138 | - | clk.fire() | |||
| 139 | 120 | ||||
| 140 | - | select { | 121 | + | got := nextChange(t, changes) |
| 141 | - | case got := <-changes: | 122 | + | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent) |
| 142 | - | require.Equal(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, got.cfg.ThemeAccent) | 123 | + | require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings) |
| 143 | - | require.Equal(t, []domain.Warning{{Line: 1, Msg: "invalid theme.accent \"invalid\""}}, got.warnings) | 124 | + | stop() |
| 144 | - | case <-time.After(2 * time.Second): | |||
| 145 | - | t.Fatal("timed out waiting for config reload") | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | cancel() | |||
| 149 | - | select { | |||
| 150 | - | case err := <-done: | |||
| 151 | - | require.True(t, errors.Is(err, context.Canceled)) | |||
| 152 | - | case <-time.After(2 * time.Second): | |||
| 153 | - | t.Fatal("timed out waiting for config watcher shutdown") | |||
| 154 | - | } | |||
| 155 | } | 125 | } | ||
M internal/app/daemon_run.go +1 -1
| @@ -300,7 +300,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 300 | 300 | watchCtx, stopWatch := context.WithCancel(ctx) | |
| 301 | 301 | defer stopWatch() | |
| 302 | 302 | go func() { | |
| 303 | - | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |
| 303 | + | if err := config.Watch(watchCtx, configPath, func(cfg domain.Config, warnings []domain.Warning) { | |
| 304 | 304 | logConfigWarnings(log, warnings) | |
| 305 | 305 | d.ApplyConfig(cfg) | |
| 306 | 306 | }); err != nil && !errors.Is(err, context.Canceled) { | |
| @@ -300,7 +300,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||||
| 300 | watchCtx, stopWatch := context.WithCancel(ctx) | 300 | watchCtx, stopWatch := context.WithCancel(ctx) | ||
| 301 | defer stopWatch() | 301 | defer stopWatch() | ||
| 302 | go func() { | 302 | go func() { | ||
| 303 | - | if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) { | 303 | + | if err := config.Watch(watchCtx, configPath, func(cfg domain.Config, warnings []domain.Warning) { |
| 304 | logConfigWarnings(log, warnings) | 304 | logConfigWarnings(log, warnings) | ||
| 305 | d.ApplyConfig(cfg) | 305 | d.ApplyConfig(cfg) | ||
| 306 | }); err != nil && !errors.Is(err, context.Canceled) { | 306 | }); err != nil && !errors.Is(err, context.Canceled) { | ||