merge: live config reload
6 files · +305 -9
M README.md +1 -1
| @@ -19,7 +19,7 @@ command = ["foot"] | |||
| 19 | 19 | gaps = 8 | |
| 20 | 20 | ``` | |
| 21 | 21 | ||
| 22 | - | See `examples/config.toml` for all defaults. | |
| 22 | + | See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place. | |
| 23 | 23 | ||
| 24 | 24 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | |
| 25 | 25 | ||
| @@ -19,7 +19,7 @@ command = ["foot"] | |||||
| 19 | gaps = 8 | 19 | gaps = 8 | ||
| 20 | ``` | 20 | ``` | ||
| 21 | 21 | ||||
| 22 | - | See `examples/config.toml` for all defaults. | 22 | + | See `examples/config.toml` for all defaults. Configuration reloads automatically when the file changes (including atomic replacements). Layout gaps, widths and presets, binds, background, and terminal command apply live; `keyboard.*` requires a restart for now. Invalid or removed files leave the current configuration in place. |
| 23 | 23 | ||||
| 24 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | 24 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | ||
| 25 | 25 | ||||
M cmd/nefertty/main.go +1 -1
| @@ -163,7 +163,7 @@ func run() error { | |||
| 163 | 163 | log := logging.For(ctx, "app") | |
| 164 | 164 | configLog := logging.For(ctx, "config") | |
| 165 | 165 | configLog.Info().Str("path", path).Msg("loaded config") | |
| 166 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { | |
| 166 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { | |
| 167 | 167 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 168 | 168 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 169 | 169 | return nil | |
| @@ -163,7 +163,7 @@ func run() error { | |||||
| 163 | log := logging.For(ctx, "app") | 163 | log := logging.For(ctx, "app") | ||
| 164 | configLog := logging.For(ctx, "config") | 164 | configLog := logging.For(ctx, "config") | ||
| 165 | configLog.Info().Str("path", path).Msg("loaded config") | 165 | configLog.Info().Str("path", path).Msg("loaded config") | ||
| 166 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { | 166 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { |
| 167 | // SIGINT and SIGTERM cancel the context and are clean exits. | 167 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 168 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | 168 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
| 169 | return nil | 169 | return nil | ||
A internal/adapters/config/watch.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "time" | |
| 9 | + | "unsafe" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "golang.org/x/sys/unix" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var directoryPollInterval = 2 * time.Second | |
| 17 | + | ||
| 18 | + | // Watch observes the parent directory so atomic file replacements are detected. | |
| 19 | + | // If the directory is absent, it retries every two seconds until it appears. | |
| 20 | + | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error { | |
| 21 | + | dir, name := filepath.Dir(path), filepath.Base(path) | |
| 22 | + | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 23 | + | if err != nil { | |
| 24 | + | return err | |
| 25 | + | } | |
| 26 | + | defer unix.Close(fd) | |
| 27 | + | const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |
| 28 | + | wd := -1 | |
| 29 | + | var pending time.Time | |
| 30 | + | buf := make([]byte, 4096) | |
| 31 | + | for { | |
| 32 | + | if ctx.Err() != nil { | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | if wd < 0 { | |
| 36 | + | wd, err = unix.InotifyAddWatch(fd, dir, mask) | |
| 37 | + | if errors.Is(err, unix.ENOENT) { | |
| 38 | + | select { | |
| 39 | + | case <-ctx.Done(): | |
| 40 | + | return nil | |
| 41 | + | case <-time.After(directoryPollInterval): | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | if err != nil { | |
| 46 | + | return err | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | timeout := 200 | |
| 50 | + | if !pending.IsZero() { | |
| 51 | + | remaining := time.Until(pending) | |
| 52 | + | if remaining <= 0 { | |
| 53 | + | pending = time.Time{} | |
| 54 | + | cfg, loadErr := Load(path) | |
| 55 | + | switch { | |
| 56 | + | case os.IsNotExist(loadErr): | |
| 57 | + | log.Warn().Msg("config file removed; keeping current config") | |
| 58 | + | case loadErr != nil: | |
| 59 | + | log.Warn().Err(loadErr).Msg("config reload rejected") | |
| 60 | + | default: | |
| 61 | + | select { | |
| 62 | + | case <-ctx.Done(): | |
| 63 | + | return nil | |
| 64 | + | case out <- ports.ConfigChanged{Config: cfg}: | |
| 65 | + | } | |
| 66 | + | log.Info().Msg("config reloaded") | |
| 67 | + | } | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | if remaining < 200*time.Millisecond { | |
| 71 | + | timeout = int(remaining.Milliseconds()) + 1 | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 75 | + | _, err = unix.Poll(fds, timeout) | |
| 76 | + | if err == unix.EINTR { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | if err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | if fds[0].Revents&unix.POLLIN == 0 { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | for { | |
| 86 | + | n, readErr := unix.Read(fd, buf) | |
| 87 | + | if readErr == unix.EAGAIN { | |
| 88 | + | break | |
| 89 | + | } | |
| 90 | + | if readErr == unix.EINTR { | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | if readErr != nil { | |
| 94 | + | return readErr | |
| 95 | + | } | |
| 96 | + | if n == 0 { | |
| 97 | + | break | |
| 98 | + | } | |
| 99 | + | for offset := 0; offset+unix.SizeofInotifyEvent <= n; { | |
| 100 | + | event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset])) | |
| 101 | + | size := unix.SizeofInotifyEvent + int(event.Len) | |
| 102 | + | if offset+size > n { | |
| 103 | + | break | |
| 104 | + | } | |
| 105 | + | if event.Mask&unix.IN_IGNORED != 0 { | |
| 106 | + | wd = -1 | |
| 107 | + | } | |
| 108 | + | if event.Len > 0 { | |
| 109 | + | file := buf[offset+unix.SizeofInotifyEvent : offset+size] | |
| 110 | + | for len(file) > 0 && file[len(file)-1] == 0 { | |
| 111 | + | file = file[:len(file)-1] | |
| 112 | + | } | |
| 113 | + | if string(file) == name && event.Mask&mask != 0 { | |
| 114 | + | pending = time.Now().Add(100 * time.Millisecond) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | offset += size | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "time" | |||
| 9 | + | "unsafe" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var directoryPollInterval = 2 * time.Second | |||
| 17 | + | ||||
| 18 | + | // Watch observes the parent directory so atomic file replacements are detected. | |||
| 19 | + | // If the directory is absent, it retries every two seconds until it appears. | |||
| 20 | + | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error { | |||
| 21 | + | dir, name := filepath.Dir(path), filepath.Base(path) | |||
| 22 | + | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |||
| 23 | + | if err != nil { | |||
| 24 | + | return err | |||
| 25 | + | } | |||
| 26 | + | defer unix.Close(fd) | |||
| 27 | + | const mask = unix.IN_CLOSE_WRITE | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |||
| 28 | + | wd := -1 | |||
| 29 | + | var pending time.Time | |||
| 30 | + | buf := make([]byte, 4096) | |||
| 31 | + | for { | |||
| 32 | + | if ctx.Err() != nil { | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | if wd < 0 { | |||
| 36 | + | wd, err = unix.InotifyAddWatch(fd, dir, mask) | |||
| 37 | + | if errors.Is(err, unix.ENOENT) { | |||
| 38 | + | select { | |||
| 39 | + | case <-ctx.Done(): | |||
| 40 | + | return nil | |||
| 41 | + | case <-time.After(directoryPollInterval): | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | if err != nil { | |||
| 46 | + | return err | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | timeout := 200 | |||
| 50 | + | if !pending.IsZero() { | |||
| 51 | + | remaining := time.Until(pending) | |||
| 52 | + | if remaining <= 0 { | |||
| 53 | + | pending = time.Time{} | |||
| 54 | + | cfg, loadErr := Load(path) | |||
| 55 | + | switch { | |||
| 56 | + | case os.IsNotExist(loadErr): | |||
| 57 | + | log.Warn().Msg("config file removed; keeping current config") | |||
| 58 | + | case loadErr != nil: | |||
| 59 | + | log.Warn().Err(loadErr).Msg("config reload rejected") | |||
| 60 | + | default: | |||
| 61 | + | select { | |||
| 62 | + | case <-ctx.Done(): | |||
| 63 | + | return nil | |||
| 64 | + | case out <- ports.ConfigChanged{Config: cfg}: | |||
| 65 | + | } | |||
| 66 | + | log.Info().Msg("config reloaded") | |||
| 67 | + | } | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | if remaining < 200*time.Millisecond { | |||
| 71 | + | timeout = int(remaining.Milliseconds()) + 1 | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |||
| 75 | + | _, err = unix.Poll(fds, timeout) | |||
| 76 | + | if err == unix.EINTR { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | if err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | if fds[0].Revents&unix.POLLIN == 0 { | |||
| 83 | + | continue | |||
| 84 | + | } | |||
| 85 | + | for { | |||
| 86 | + | n, readErr := unix.Read(fd, buf) | |||
| 87 | + | if readErr == unix.EAGAIN { | |||
| 88 | + | break | |||
| 89 | + | } | |||
| 90 | + | if readErr == unix.EINTR { | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 93 | + | if readErr != nil { | |||
| 94 | + | return readErr | |||
| 95 | + | } | |||
| 96 | + | if n == 0 { | |||
| 97 | + | break | |||
| 98 | + | } | |||
| 99 | + | for offset := 0; offset+unix.SizeofInotifyEvent <= n; { | |||
| 100 | + | event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset])) | |||
| 101 | + | size := unix.SizeofInotifyEvent + int(event.Len) | |||
| 102 | + | if offset+size > n { | |||
| 103 | + | break | |||
| 104 | + | } | |||
| 105 | + | if event.Mask&unix.IN_IGNORED != 0 { | |||
| 106 | + | wd = -1 | |||
| 107 | + | } | |||
| 108 | + | if event.Len > 0 { | |||
| 109 | + | file := buf[offset+unix.SizeofInotifyEvent : offset+size] | |||
| 110 | + | for len(file) > 0 && file[len(file)-1] == 0 { | |||
| 111 | + | file = file[:len(file)-1] | |||
| 112 | + | } | |||
| 113 | + | if string(file) == name && event.Mask&mask != 0 { | |||
| 114 | + | pending = time.Now().Add(100 * time.Millisecond) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | offset += size | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
A internal/adapters/config/watch_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/logging" | |
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestWatch(t *testing.T) { | |
| 15 | + | for _, later := range []bool{false, true} { | |
| 16 | + | t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) { | |
| 17 | + | dir := filepath.Join(t.TempDir(), "config") | |
| 18 | + | if !later { | |
| 19 | + | if err := os.Mkdir(dir, 0700); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | path := filepath.Join(dir, "config.toml") | |
| 24 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 25 | + | out := make(chan ports.ConfigChanged, 8) | |
| 26 | + | done := make(chan error, 1) | |
| 27 | + | old := directoryPollInterval | |
| 28 | + | directoryPollInterval = 20 * time.Millisecond | |
| 29 | + | defer func() { directoryPollInterval = old }() | |
| 30 | + | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |
| 31 | + | defer func() { | |
| 32 | + | cancel() | |
| 33 | + | if err := <-done; err != nil { | |
| 34 | + | t.Error(err) | |
| 35 | + | } | |
| 36 | + | }() | |
| 37 | + | if later { | |
| 38 | + | time.Sleep(40 * time.Millisecond) | |
| 39 | + | if err := os.Mkdir(dir, 0700); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | time.Sleep(80 * time.Millisecond) | |
| 43 | + | } else { | |
| 44 | + | time.Sleep(40 * time.Millisecond) | |
| 45 | + | } | |
| 46 | + | write := func(file, body string) { | |
| 47 | + | t.Helper() | |
| 48 | + | if err := os.WriteFile(file, []byte(body), 0600); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | write(path, "[background]\ncolor='#000000'\n") | |
| 53 | + | receive := func(want string) { | |
| 54 | + | t.Helper() | |
| 55 | + | select { | |
| 56 | + | case c := <-out: | |
| 57 | + | if c.Config.Background.Color != want { | |
| 58 | + | t.Fatalf("color %s, want %s", c.Config.Background.Color, want) | |
| 59 | + | } | |
| 60 | + | case <-time.After(time.Second): | |
| 61 | + | t.Fatal("no config change") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | receive("#000000") | |
| 65 | + | tmp := filepath.Join(dir, "temp") | |
| 66 | + | write(tmp, "[background]\ncolor='#ff0000'\n") | |
| 67 | + | if err := os.Rename(tmp, path); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | receive("#ff0000") | |
| 71 | + | for i := 0; i < 3; i++ { | |
| 72 | + | write(path, "[background]\ncolor='#00ff00'\n") | |
| 73 | + | time.Sleep(15 * time.Millisecond) | |
| 74 | + | } | |
| 75 | + | receive("#00ff00") | |
| 76 | + | select { | |
| 77 | + | case <-out: | |
| 78 | + | t.Fatal("duplicate reload") | |
| 79 | + | case <-time.After(250 * time.Millisecond): | |
| 80 | + | } | |
| 81 | + | write(path, "[background]\ncolor='bad'\n") | |
| 82 | + | select { | |
| 83 | + | case <-out: | |
| 84 | + | t.Fatal("invalid config sent") | |
| 85 | + | case <-time.After(500 * time.Millisecond): | |
| 86 | + | } | |
| 87 | + | if err := os.Remove(path); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | select { | |
| 91 | + | case <-out: | |
| 92 | + | t.Fatal("removed config sent") | |
| 93 | + | case <-time.After(250 * time.Millisecond): | |
| 94 | + | } | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestWatch(t *testing.T) { | |||
| 15 | + | for _, later := range []bool{false, true} { | |||
| 16 | + | t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) { | |||
| 17 | + | dir := filepath.Join(t.TempDir(), "config") | |||
| 18 | + | if !later { | |||
| 19 | + | if err := os.Mkdir(dir, 0700); err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | path := filepath.Join(dir, "config.toml") | |||
| 24 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 25 | + | out := make(chan ports.ConfigChanged, 8) | |||
| 26 | + | done := make(chan error, 1) | |||
| 27 | + | old := directoryPollInterval | |||
| 28 | + | directoryPollInterval = 20 * time.Millisecond | |||
| 29 | + | defer func() { directoryPollInterval = old }() | |||
| 30 | + | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |||
| 31 | + | defer func() { | |||
| 32 | + | cancel() | |||
| 33 | + | if err := <-done; err != nil { | |||
| 34 | + | t.Error(err) | |||
| 35 | + | } | |||
| 36 | + | }() | |||
| 37 | + | if later { | |||
| 38 | + | time.Sleep(40 * time.Millisecond) | |||
| 39 | + | if err := os.Mkdir(dir, 0700); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | time.Sleep(80 * time.Millisecond) | |||
| 43 | + | } else { | |||
| 44 | + | time.Sleep(40 * time.Millisecond) | |||
| 45 | + | } | |||
| 46 | + | write := func(file, body string) { | |||
| 47 | + | t.Helper() | |||
| 48 | + | if err := os.WriteFile(file, []byte(body), 0600); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | write(path, "[background]\ncolor='#000000'\n") | |||
| 53 | + | receive := func(want string) { | |||
| 54 | + | t.Helper() | |||
| 55 | + | select { | |||
| 56 | + | case c := <-out: | |||
| 57 | + | if c.Config.Background.Color != want { | |||
| 58 | + | t.Fatalf("color %s, want %s", c.Config.Background.Color, want) | |||
| 59 | + | } | |||
| 60 | + | case <-time.After(time.Second): | |||
| 61 | + | t.Fatal("no config change") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | receive("#000000") | |||
| 65 | + | tmp := filepath.Join(dir, "temp") | |||
| 66 | + | write(tmp, "[background]\ncolor='#ff0000'\n") | |||
| 67 | + | if err := os.Rename(tmp, path); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | receive("#ff0000") | |||
| 71 | + | for i := 0; i < 3; i++ { | |||
| 72 | + | write(path, "[background]\ncolor='#00ff00'\n") | |||
| 73 | + | time.Sleep(15 * time.Millisecond) | |||
| 74 | + | } | |||
| 75 | + | receive("#00ff00") | |||
| 76 | + | select { | |||
| 77 | + | case <-out: | |||
| 78 | + | t.Fatal("duplicate reload") | |||
| 79 | + | case <-time.After(250 * time.Millisecond): | |||
| 80 | + | } | |||
| 81 | + | write(path, "[background]\ncolor='bad'\n") | |||
| 82 | + | select { | |||
| 83 | + | case <-out: | |||
| 84 | + | t.Fatal("invalid config sent") | |||
| 85 | + | case <-time.After(500 * time.Millisecond): | |||
| 86 | + | } | |||
| 87 | + | if err := os.Remove(path); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | select { | |||
| 91 | + | case <-out: | |||
| 92 | + | t.Fatal("removed config sent") | |||
| 93 | + | case <-time.After(250 * time.Millisecond): | |||
| 94 | + | } | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | } | |||
M internal/app/app.go +16 -5
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "sync" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 14 | 15 | "github.com/bnema/nefertty/internal/adapters/headless" | |
| 15 | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 16 | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| @@ -25,6 +26,7 @@ import ( | |||
| 25 | 26 | type Options struct { | |
| 26 | 27 | Backend string | |
| 27 | 28 | Config ports.Config | |
| 29 | + | ConfigPath string | |
| 28 | 30 | Timeout time.Duration | |
| 29 | 31 | NoTerminal bool | |
| 30 | 32 | ScreenshotDir string | |
| @@ -50,14 +52,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 50 | 52 | client := make(chan ports.ClientEvent, 32) | |
| 51 | 53 | input := make(chan ports.InputEvent, 32) | |
| 52 | 54 | output := make(chan ports.OutputEvent, 32) | |
| 53 | - | config := make(chan ports.ConfigChanged, 8) | |
| 55 | + | configChanges := make(chan ports.ConfigChanged, 8) | |
| 54 | 56 | commands := make(chan ports.ClientCommand, 32) | |
| 55 | 57 | spawn := make(chan ports.SpawnRequest, 32) | |
| 56 | 58 | scenes := make(chan ports.Scene, 1) | |
| 57 | 59 | configErrors := make(chan error, 8) | |
| 58 | 60 | renderScenes := make(chan ports.Scene, 1) | |
| 59 | 61 | contents := make(chan ports.SurfaceContent, 64) | |
| 60 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | |
| 62 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | |
| 61 | 63 | c, err := core.New(opts.Config, ch) | |
| 62 | 64 | if err != nil { | |
| 63 | 65 | return err | |
| @@ -83,8 +85,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 83 | 85 | inject(input) | |
| 84 | 86 | } | |
| 85 | 87 | var workers sync.WaitGroup | |
| 86 | - | workers.Add(6) | |
| 87 | - | done := make(chan error, 6) | |
| 88 | + | workers.Add(7) | |
| 89 | + | done := make(chan error, 7) | |
| 90 | + | path := opts.ConfigPath | |
| 91 | + | if path == "" { | |
| 92 | + | path = config.DefaultPath() | |
| 93 | + | } | |
| 94 | + | go func() { | |
| 95 | + | defer workers.Done() | |
| 96 | + | done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config")) | |
| 97 | + | }() | |
| 88 | 98 | script := make(chan string) | |
| 89 | 99 | go func() { | |
| 90 | 100 | defer workers.Done() | |
| @@ -147,6 +157,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 147 | 157 | ||
| 148 | 158 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | |
| 149 | 159 | log := logging.For(ctx, "core") | |
| 160 | + | configLog := logging.For(ctx, "config") | |
| 150 | 161 | for { | |
| 151 | 162 | select { | |
| 152 | 163 | case <-ctx.Done(): | |
| @@ -172,7 +183,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||
| 172 | 183 | } | |
| 173 | 184 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | |
| 174 | 185 | case err := <-configErrors: | |
| 175 | - | log.Debug().Err(err).Msg("config rejected") | |
| 186 | + | configLog.Warn().Err(err).Msg("config rejected") | |
| 176 | 187 | } | |
| 177 | 188 | } | |
| 178 | 189 | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "sync" | 11 | "sync" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 14 | "github.com/bnema/nefertty/internal/adapters/headless" | 15 | "github.com/bnema/nefertty/internal/adapters/headless" | ||
| 15 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | ||
| 16 | "github.com/bnema/nefertty/internal/adapters/launcher" | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | ||
| @@ -25,6 +26,7 @@ import ( | |||||
| 25 | type Options struct { | 26 | type Options struct { | ||
| 26 | Backend string | 27 | Backend string | ||
| 27 | Config ports.Config | 28 | Config ports.Config | ||
| 29 | + | ConfigPath string | |||
| 28 | Timeout time.Duration | 30 | Timeout time.Duration | ||
| 29 | NoTerminal bool | 31 | NoTerminal bool | ||
| 30 | ScreenshotDir string | 32 | ScreenshotDir string | ||
| @@ -50,14 +52,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 50 | client := make(chan ports.ClientEvent, 32) | 52 | client := make(chan ports.ClientEvent, 32) | ||
| 51 | input := make(chan ports.InputEvent, 32) | 53 | input := make(chan ports.InputEvent, 32) | ||
| 52 | output := make(chan ports.OutputEvent, 32) | 54 | output := make(chan ports.OutputEvent, 32) | ||
| 53 | - | config := make(chan ports.ConfigChanged, 8) | 55 | + | configChanges := make(chan ports.ConfigChanged, 8) |
| 54 | commands := make(chan ports.ClientCommand, 32) | 56 | commands := make(chan ports.ClientCommand, 32) | ||
| 55 | spawn := make(chan ports.SpawnRequest, 32) | 57 | spawn := make(chan ports.SpawnRequest, 32) | ||
| 56 | scenes := make(chan ports.Scene, 1) | 58 | scenes := make(chan ports.Scene, 1) | ||
| 57 | configErrors := make(chan error, 8) | 59 | configErrors := make(chan error, 8) | ||
| 58 | renderScenes := make(chan ports.Scene, 1) | 60 | renderScenes := make(chan ports.Scene, 1) | ||
| 59 | contents := make(chan ports.SurfaceContent, 64) | 61 | contents := make(chan ports.SurfaceContent, 64) | ||
| 60 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | 62 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} |
| 61 | c, err := core.New(opts.Config, ch) | 63 | c, err := core.New(opts.Config, ch) | ||
| 62 | if err != nil { | 64 | if err != nil { | ||
| 63 | return err | 65 | return err | ||
| @@ -83,8 +85,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 83 | inject(input) | 85 | inject(input) | ||
| 84 | } | 86 | } | ||
| 85 | var workers sync.WaitGroup | 87 | var workers sync.WaitGroup | ||
| 86 | - | workers.Add(6) | 88 | + | workers.Add(7) |
| 87 | - | done := make(chan error, 6) | 89 | + | done := make(chan error, 7) |
| 90 | + | path := opts.ConfigPath | |||
| 91 | + | if path == "" { | |||
| 92 | + | path = config.DefaultPath() | |||
| 93 | + | } | |||
| 94 | + | go func() { | |||
| 95 | + | defer workers.Done() | |||
| 96 | + | done <- config.Watch(ctx, path, configChanges, logging.For(ctx, "config")) | |||
| 97 | + | }() | |||
| 88 | script := make(chan string) | 98 | script := make(chan string) | ||
| 89 | go func() { | 99 | go func() { | ||
| 90 | defer workers.Done() | 100 | defer workers.Done() | ||
| @@ -147,6 +157,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 147 | 157 | ||||
| 148 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | 158 | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | ||
| 149 | log := logging.For(ctx, "core") | 159 | log := logging.For(ctx, "core") | ||
| 160 | + | configLog := logging.For(ctx, "config") | |||
| 150 | for { | 161 | for { | ||
| 151 | select { | 162 | select { | ||
| 152 | case <-ctx.Done(): | 163 | case <-ctx.Done(): | ||
| @@ -172,7 +183,7 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||||
| 172 | } | 183 | } | ||
| 173 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | 184 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | ||
| 174 | case err := <-configErrors: | 185 | case err := <-configErrors: | ||
| 175 | - | log.Debug().Err(err).Msg("config rejected") | 186 | + | configLog.Warn().Err(err).Msg("config rejected") |
| 176 | } | 187 | } | ||
| 177 | } | 188 | } | ||
| 178 | } | 189 | } | ||
M internal/app/app_test.go +69 -2
| @@ -2,8 +2,7 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "github.com/bnema/nefertty/internal/adapters/config" | |
| 6 | - | "github.com/bnema/nefertty/internal/ports" | |
| 5 | + | "image/color" | |
| 7 | 6 | "image/png" | |
| 8 | 7 | "os" | |
| 9 | 8 | "os/exec" | |
| @@ -11,8 +10,76 @@ import ( | |||
| 11 | 10 | "strings" | |
| 12 | 11 | "testing" | |
| 13 | 12 | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 15 | + | "github.com/bnema/nefertty/internal/ports" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 18 | + | func TestHeadlessConfigReload(t *testing.T) { | |
| 19 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 20 | + | dir := t.TempDir() | |
| 21 | + | path := filepath.Join(dir, "config.toml") | |
| 22 | + | if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | cfg, err := config.Load(path) | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | shots := t.TempDir() | |
| 30 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 31 | + | done := make(chan error, 1) | |
| 32 | + | go func() { | |
| 33 | + | done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots}) | |
| 34 | + | }() | |
| 35 | + | defer func() { | |
| 36 | + | cancel() | |
| 37 | + | select { | |
| 38 | + | case err := <-done: | |
| 39 | + | if err != nil { | |
| 40 | + | t.Errorf("Run: %v", err) | |
| 41 | + | } | |
| 42 | + | case <-time.After(3 * time.Second): | |
| 43 | + | t.Error("Run did not stop") | |
| 44 | + | } | |
| 45 | + | }() | |
| 46 | + | latest := filepath.Join(shots, "latest.png") | |
| 47 | + | waitColor := func(want color.RGBA, deadline time.Time) bool { | |
| 48 | + | for time.Now().Before(deadline) { | |
| 49 | + | f, err := os.Open(latest) | |
| 50 | + | if err == nil { | |
| 51 | + | img, decodeErr := png.Decode(f) | |
| 52 | + | f.Close() | |
| 53 | + | if decodeErr == nil { | |
| 54 | + | r, g, b, _ := img.At(1900, 10).RGBA() | |
| 55 | + | if uint8(r>>8) == want.R && uint8(g>>8) == want.G && uint8(b>>8) == want.B { | |
| 56 | + | return true | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | select { | |
| 61 | + | case err := <-done: | |
| 62 | + | if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| 63 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 64 | + | } | |
| 65 | + | t.Fatalf("app exited early: %v", err) | |
| 66 | + | default: | |
| 67 | + | } | |
| 68 | + | time.Sleep(20 * time.Millisecond) | |
| 69 | + | } | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) { | |
| 73 | + | t.Fatal("first black frame missing") | |
| 74 | + | } | |
| 75 | + | if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) { | |
| 79 | + | t.Fatal("red frame missing after reload") | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 16 | 83 | func TestQuitJoinsWorkers(t *testing.T) { | |
| 17 | 84 | done := make(chan error, 1) | |
| 18 | 85 | go func() { | |
| @@ -2,8 +2,7 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "github.com/bnema/nefertty/internal/adapters/config" | 5 | + | "image/color" |
| 6 | - | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | "image/png" | 6 | "image/png" | ||
| 8 | "os" | 7 | "os" | ||
| 9 | "os/exec" | 8 | "os/exec" | ||
| @@ -11,8 +10,76 @@ import ( | |||||
| 11 | "strings" | 10 | "strings" | ||
| 12 | "testing" | 11 | "testing" | ||
| 13 | "time" | 12 | "time" | ||
| 13 | + | ||||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 15 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 18 | + | func TestHeadlessConfigReload(t *testing.T) { | |||
| 19 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 20 | + | dir := t.TempDir() | |||
| 21 | + | path := filepath.Join(dir, "config.toml") | |||
| 22 | + | if err := os.WriteFile(path, []byte("[background]\ncolor='#000000'\n"), 0600); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | cfg, err := config.Load(path) | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | shots := t.TempDir() | |||
| 30 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 31 | + | done := make(chan error, 1) | |||
| 32 | + | go func() { | |||
| 33 | + | done <- Run(ctx, Options{Backend: "headless", Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots}) | |||
| 34 | + | }() | |||
| 35 | + | defer func() { | |||
| 36 | + | cancel() | |||
| 37 | + | select { | |||
| 38 | + | case err := <-done: | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Errorf("Run: %v", err) | |||
| 41 | + | } | |||
| 42 | + | case <-time.After(3 * time.Second): | |||
| 43 | + | t.Error("Run did not stop") | |||
| 44 | + | } | |||
| 45 | + | }() | |||
| 46 | + | latest := filepath.Join(shots, "latest.png") | |||
| 47 | + | waitColor := func(want color.RGBA, deadline time.Time) bool { | |||
| 48 | + | for time.Now().Before(deadline) { | |||
| 49 | + | f, err := os.Open(latest) | |||
| 50 | + | if err == nil { | |||
| 51 | + | img, decodeErr := png.Decode(f) | |||
| 52 | + | f.Close() | |||
| 53 | + | if decodeErr == nil { | |||
| 54 | + | r, g, b, _ := img.At(1900, 10).RGBA() | |||
| 55 | + | if uint8(r>>8) == want.R && uint8(g>>8) == want.G && uint8(b>>8) == want.B { | |||
| 56 | + | return true | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | select { | |||
| 61 | + | case err := <-done: | |||
| 62 | + | if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |||
| 63 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 64 | + | } | |||
| 65 | + | t.Fatalf("app exited early: %v", err) | |||
| 66 | + | default: | |||
| 67 | + | } | |||
| 68 | + | time.Sleep(20 * time.Millisecond) | |||
| 69 | + | } | |||
| 70 | + | return false | |||
| 71 | + | } | |||
| 72 | + | if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) { | |||
| 73 | + | t.Fatal("first black frame missing") | |||
| 74 | + | } | |||
| 75 | + | if err := os.WriteFile(path, []byte("[background]\ncolor='#ff0000'\n"), 0600); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) { | |||
| 79 | + | t.Fatal("red frame missing after reload") | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 16 | func TestQuitJoinsWorkers(t *testing.T) { | 83 | func TestQuitJoinsWorkers(t *testing.T) { | ||
| 17 | done := make(chan error, 1) | 84 | done := make(chan error, 1) | ||
| 18 | go func() { | 85 | go func() { | ||