refactor(config): share one inotify file watcher between config and theme reload
- commit
- 397c5b945c4a541f543947ff3b54b0f6816d7ecf
- author
- brice · · 2026-10-05 15:42 UTC
- parent
- b5cca9e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +205 -155
M internal/config/config.go +11 -2
| @@ -17,7 +17,10 @@ type Bar struct { | |||
| 17 | 17 | Scale float64 `toml:"scale"` | |
| 18 | 18 | // Output is the wl_output name; empty lets the compositor choose. | |
| 19 | 19 | Output string `toml:"output"` | |
| 20 | - | // Background and Foreground are #rrggbb. | |
| 20 | + | // Theme is "auto" (the colors of the terminal in use) or the path of a | |
| 21 | + | // terminal theme or config file: kitty, foot, ghostty or alacritty. | |
| 22 | + | Theme string `toml:"theme"` | |
| 23 | + | // Background and Foreground, as #rrggbb, override the theme's. | |
| 21 | 24 | Background string `toml:"background"` | |
| 22 | 25 | Foreground string `toml:"foreground"` | |
| 23 | 26 | } | |
| @@ -39,7 +42,7 @@ type Config struct { | |||
| 39 | 42 | func Default() Config { | |
| 40 | 43 | return Config{Bar: Bar{ | |
| 41 | 44 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | |
| 42 | - | Background: "#1e1e2e", Foreground: "#cdd6f4", | |
| 45 | + | Theme: "auto", | |
| 43 | 46 | }} | |
| 44 | 47 | } | |
| 45 | 48 | ||
| @@ -83,10 +86,16 @@ func (c *Config) Validate() error { | |||
| 83 | 86 | return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale) | |
| 84 | 87 | } | |
| 85 | 88 | for _, col := range []string{b.Background, b.Foreground} { | |
| 89 | + | if col == "" { | |
| 90 | + | continue // taken from the theme | |
| 91 | + | } | |
| 86 | 92 | if _, err := ParseColor(col); err != nil { | |
| 87 | 93 | return err | |
| 88 | 94 | } | |
| 89 | 95 | } | |
| 96 | + | if b.Theme == "" { | |
| 97 | + | return fmt.Errorf("config: bar.theme is empty; use \"auto\" or the path of a theme file") | |
| 98 | + | } | |
| 90 | 99 | seen := map[string]bool{} | |
| 91 | 100 | for i := range c.Module { | |
| 92 | 101 | m := &c.Module[i] | |
| @@ -17,7 +17,10 @@ type Bar struct { | |||||
| 17 | Scale float64 `toml:"scale"` | 17 | Scale float64 `toml:"scale"` | ||
| 18 | // Output is the wl_output name; empty lets the compositor choose. | 18 | // Output is the wl_output name; empty lets the compositor choose. | ||
| 19 | Output string `toml:"output"` | 19 | Output string `toml:"output"` | ||
| 20 | - | // Background and Foreground are #rrggbb. | 20 | + | // Theme is "auto" (the colors of the terminal in use) or the path of a |
| 21 | + | // terminal theme or config file: kitty, foot, ghostty or alacritty. | |||
| 22 | + | Theme string `toml:"theme"` | |||
| 23 | + | // Background and Foreground, as #rrggbb, override the theme's. | |||
| 21 | Background string `toml:"background"` | 24 | Background string `toml:"background"` | ||
| 22 | Foreground string `toml:"foreground"` | 25 | Foreground string `toml:"foreground"` | ||
| 23 | } | 26 | } | ||
| @@ -39,7 +42,7 @@ type Config struct { | |||||
| 39 | func Default() Config { | 42 | func Default() Config { | ||
| 40 | return Config{Bar: Bar{ | 43 | return Config{Bar: Bar{ | ||
| 41 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | 44 | Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1, | ||
| 42 | - | Background: "#1e1e2e", Foreground: "#cdd6f4", | 45 | + | Theme: "auto", |
| 43 | }} | 46 | }} | ||
| 44 | } | 47 | } | ||
| 45 | 48 | ||||
| @@ -83,10 +86,16 @@ func (c *Config) Validate() error { | |||||
| 83 | return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale) | 86 | return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale) | ||
| 84 | } | 87 | } | ||
| 85 | for _, col := range []string{b.Background, b.Foreground} { | 88 | for _, col := range []string{b.Background, b.Foreground} { | ||
| 89 | + | if col == "" { | |||
| 90 | + | continue // taken from the theme | |||
| 91 | + | } | |||
| 86 | if _, err := ParseColor(col); err != nil { | 92 | if _, err := ParseColor(col); err != nil { | ||
| 87 | return err | 93 | return err | ||
| 88 | } | 94 | } | ||
| 89 | } | 95 | } | ||
| 96 | + | if b.Theme == "" { | |||
| 97 | + | return fmt.Errorf("config: bar.theme is empty; use \"auto\" or the path of a theme file") | |||
| 98 | + | } | |||
| 90 | seen := map[string]bool{} | 99 | seen := map[string]bool{} | ||
| 91 | for i := range c.Module { | 100 | for i := range c.Module { | ||
| 92 | m := &c.Module[i] | 101 | m := &c.Module[i] | ||
M internal/config/watch.go +32 -153
| @@ -5,171 +5,50 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "log/slog" | |
| 7 | 7 | "os" | |
| 8 | - | "path/filepath" | |
| 9 | 8 | "reflect" | |
| 10 | - | "slices" | |
| 11 | 9 | "time" | |
| 12 | - | "unsafe" | |
| 13 | 10 | ||
| 14 | - | "golang.org/x/sys/unix" | |
| 11 | + | "git.bnema.dev/bnema/neferbar/internal/fswatch" | |
| 15 | 12 | ) | |
| 16 | 13 | ||
| 17 | - | const ( | |
| 18 | - | debounce = 100 * time.Millisecond | |
| 19 | - | // retryEvery is how often a missing directory is looked for again. | |
| 20 | - | retryEvery = 2 * time.Second | |
| 21 | - | // inotify mask: every way a file can be written, replaced or removed. | |
| 22 | - | // IN_MODIFY restarts the debounce on each write, so a truncating in-place | |
| 23 | - | // save is never read half written. | |
| 24 | - | watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |
| 25 | - | ) | |
| 26 | - | ||
| 27 | - | // watchedFiles lists the config path and every symlink target it goes through | |
| 28 | - | // (dotfile managers), so retargeting a link is noticed. | |
| 29 | - | func watchedFiles(path string) []string { | |
| 30 | - | files := []string{filepath.Clean(path)} | |
| 31 | - | for len(files) <= 40 { // the kernel's symlink limit | |
| 32 | - | cur := files[len(files)-1] | |
| 33 | - | target, err := os.Readlink(cur) | |
| 34 | - | if err != nil { | |
| 35 | - | break | |
| 36 | - | } | |
| 37 | - | if !filepath.IsAbs(target) { | |
| 38 | - | target = filepath.Join(filepath.Dir(cur), target) | |
| 39 | - | } | |
| 40 | - | target = filepath.Clean(target) | |
| 41 | - | if slices.Contains(files, target) { | |
| 42 | - | break | |
| 43 | - | } | |
| 44 | - | files = append(files, target) | |
| 45 | - | } | |
| 46 | - | return files | |
| 47 | - | } | |
| 14 | + | // debounce waits for a save to finish before the file is read. | |
| 15 | + | const debounce = 100 * time.Millisecond | |
| 48 | 16 | ||
| 49 | 17 | // Watch reloads the file at path whenever it changes and sends each valid, | |
| 50 | 18 | // different Config to out. last is the Config the caller already runs, so a | |
| 51 | 19 | // save that changes nothing sends nothing. A broken file or a deleted file is | |
| 52 | - | // logged and the caller keeps its current configuration. | |
| 53 | - | // | |
| 54 | - | // It watches the parent directory, so atomic replacements (editors that | |
| 55 | - | // write a temp file and rename it) are detected. If the directory is missing | |
| 56 | - | // it retries every two seconds. It blocks until ctx ends. | |
| 20 | + | // logged and the caller keeps its current configuration. It blocks until ctx | |
| 21 | + | // ends. | |
| 57 | 22 | func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error { | |
| 58 | - | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 59 | - | if err != nil { | |
| 60 | - | return err | |
| 61 | - | } | |
| 62 | - | defer unix.Close(fd) | |
| 63 | - | ||
| 64 | - | names := map[int32]map[string]bool{} // watch -> file names that matter in its directory | |
| 65 | - | var files []string | |
| 66 | - | var retry, pending time.Time | |
| 67 | - | rewatch := true | |
| 68 | - | buf := make([]byte, 4096) | |
| 69 | - | fds := make([]unix.PollFd, 1) | |
| 70 | - | ||
| 71 | - | for ctx.Err() == nil { | |
| 72 | - | if !retry.IsZero() && !time.Now().Before(retry) { | |
| 73 | - | rewatch = true | |
| 74 | - | } | |
| 75 | - | if rewatch { | |
| 76 | - | rewatch, retry = false, time.Time{} | |
| 77 | - | for wd := range names { | |
| 78 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 79 | - | } | |
| 80 | - | clear(names) | |
| 81 | - | files = watchedFiles(path) | |
| 82 | - | for _, file := range files { | |
| 83 | - | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), watchMask) | |
| 84 | - | if errors.Is(err, unix.ENOENT) { | |
| 85 | - | retry = time.Now().Add(retryEvery) | |
| 86 | - | continue | |
| 87 | - | } | |
| 88 | - | if err != nil { | |
| 89 | - | return err | |
| 90 | - | } | |
| 91 | - | if names[int32(wd)] == nil { | |
| 92 | - | names[int32(wd)] = map[string]bool{} | |
| 93 | - | } | |
| 94 | - | names[int32(wd)][filepath.Base(file)] = true | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | timeout := 200 // ms: also the cadence at which ctx is checked | |
| 99 | - | if !retry.IsZero() { | |
| 100 | - | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |
| 101 | - | } | |
| 102 | - | if !pending.IsZero() { | |
| 103 | - | remaining := time.Until(pending) | |
| 104 | - | if remaining <= 0 { | |
| 105 | - | pending = time.Time{} | |
| 106 | - | next, err := Load(path, true) | |
| 107 | - | switch { | |
| 108 | - | case errors.Is(err, os.ErrNotExist): | |
| 109 | - | log.Warn("config file removed; keeping the current config", "path", path) | |
| 110 | - | case err != nil: | |
| 111 | - | log.Warn("config reload failed; keeping the current config", "err", err) | |
| 112 | - | case reflect.DeepEqual(next, last): | |
| 113 | - | log.Debug("config unchanged") | |
| 114 | - | default: | |
| 115 | - | last = next | |
| 116 | - | select { | |
| 117 | - | case <-ctx.Done(): | |
| 118 | - | return nil | |
| 119 | - | case out <- next: | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | continue | |
| 123 | - | } | |
| 124 | - | timeout = min(timeout, int(remaining.Milliseconds())+1) | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN} | |
| 128 | - | if _, err = unix.Poll(fds, timeout); err != nil { | |
| 129 | - | if err == unix.EINTR { | |
| 130 | - | continue | |
| 131 | - | } | |
| 23 | + | changed := make(chan struct{}, 1) | |
| 24 | + | wctx, cancel := context.WithCancel(ctx) | |
| 25 | + | defer cancel() | |
| 26 | + | errc := make(chan error, 1) | |
| 27 | + | go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }() | |
| 28 | + | ||
| 29 | + | for { | |
| 30 | + | select { | |
| 31 | + | case <-ctx.Done(): | |
| 32 | + | return nil | |
| 33 | + | case err := <-errc: | |
| 132 | 34 | return err | |
| 133 | - | } | |
| 134 | - | if fds[0].Revents&unix.POLLIN == 0 { | |
| 135 | - | continue | |
| 136 | - | } | |
| 137 | - | for { | |
| 138 | - | n, err := unix.Read(fd, buf) | |
| 139 | - | if err == unix.EAGAIN || n == 0 { | |
| 140 | - | break | |
| 141 | - | } | |
| 142 | - | if err == unix.EINTR { | |
| 143 | - | continue | |
| 144 | - | } | |
| 145 | - | if err != nil { | |
| 146 | - | return err | |
| 147 | - | } | |
| 148 | - | for off := 0; off+unix.SizeofInotifyEvent <= n; { | |
| 149 | - | ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off])) | |
| 150 | - | size := unix.SizeofInotifyEvent + int(ev.Len) | |
| 151 | - | if off+size > n { | |
| 152 | - | break | |
| 153 | - | } | |
| 154 | - | // One of our watches vanished (its directory was removed). | |
| 155 | - | if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil { | |
| 156 | - | rewatch = true | |
| 157 | - | } | |
| 158 | - | if ev.Len > 0 { | |
| 159 | - | name := buf[off+unix.SizeofInotifyEvent : off+size] | |
| 160 | - | for len(name) > 0 && name[len(name)-1] == 0 { | |
| 161 | - | name = name[:len(name)-1] | |
| 162 | - | } | |
| 163 | - | if names[ev.Wd][string(name)] && ev.Mask&watchMask != 0 { | |
| 164 | - | pending = time.Now().Add(debounce) | |
| 165 | - | if !slices.Equal(files, watchedFiles(path)) { | |
| 166 | - | rewatch = true // a symlink now points elsewhere | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | off += size | |
| 35 | + | case <-changed: | |
| 36 | + | } | |
| 37 | + | next, err := Load(path, true) | |
| 38 | + | switch { | |
| 39 | + | case errors.Is(err, os.ErrNotExist): | |
| 40 | + | log.Warn("config file removed; keeping the current config", "path", path) | |
| 41 | + | case err != nil: | |
| 42 | + | log.Warn("config reload failed; keeping the current config", "err", err) | |
| 43 | + | case reflect.DeepEqual(next, last): | |
| 44 | + | log.Debug("config unchanged") | |
| 45 | + | default: | |
| 46 | + | last = next | |
| 47 | + | select { | |
| 48 | + | case <-ctx.Done(): | |
| 49 | + | return nil | |
| 50 | + | case out <- next: | |
| 171 | 51 | } | |
| 172 | 52 | } | |
| 173 | 53 | } | |
| 174 | - | return nil | |
| 175 | 54 | } | |
| @@ -5,171 +5,50 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | - | "path/filepath" | |||
| 9 | "reflect" | 8 | "reflect" | ||
| 10 | - | "slices" | |||
| 11 | "time" | 9 | "time" | ||
| 12 | - | "unsafe" | |||
| 13 | 10 | ||||
| 14 | - | "golang.org/x/sys/unix" | 11 | + | "git.bnema.dev/bnema/neferbar/internal/fswatch" |
| 15 | ) | 12 | ) | ||
| 16 | 13 | ||||
| 17 | - | const ( | 14 | + | // debounce waits for a save to finish before the file is read. |
| 18 | - | debounce = 100 * time.Millisecond | 15 | + | const debounce = 100 * time.Millisecond |
| 19 | - | // retryEvery is how often a missing directory is looked for again. | |||
| 20 | - | retryEvery = 2 * time.Second | |||
| 21 | - | // inotify mask: every way a file can be written, replaced or removed. | |||
| 22 | - | // IN_MODIFY restarts the debounce on each write, so a truncating in-place | |||
| 23 | - | // save is never read half written. | |||
| 24 | - | watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |||
| 25 | - | ) | |||
| 26 | - | ||||
| 27 | - | // watchedFiles lists the config path and every symlink target it goes through | |||
| 28 | - | // (dotfile managers), so retargeting a link is noticed. | |||
| 29 | - | func watchedFiles(path string) []string { | |||
| 30 | - | files := []string{filepath.Clean(path)} | |||
| 31 | - | for len(files) <= 40 { // the kernel's symlink limit | |||
| 32 | - | cur := files[len(files)-1] | |||
| 33 | - | target, err := os.Readlink(cur) | |||
| 34 | - | if err != nil { | |||
| 35 | - | break | |||
| 36 | - | } | |||
| 37 | - | if !filepath.IsAbs(target) { | |||
| 38 | - | target = filepath.Join(filepath.Dir(cur), target) | |||
| 39 | - | } | |||
| 40 | - | target = filepath.Clean(target) | |||
| 41 | - | if slices.Contains(files, target) { | |||
| 42 | - | break | |||
| 43 | - | } | |||
| 44 | - | files = append(files, target) | |||
| 45 | - | } | |||
| 46 | - | return files | |||
| 47 | - | } | |||
| 48 | 16 | ||||
| 49 | // Watch reloads the file at path whenever it changes and sends each valid, | 17 | // Watch reloads the file at path whenever it changes and sends each valid, | ||
| 50 | // different Config to out. last is the Config the caller already runs, so a | 18 | // different Config to out. last is the Config the caller already runs, so a | ||
| 51 | // save that changes nothing sends nothing. A broken file or a deleted file is | 19 | // save that changes nothing sends nothing. A broken file or a deleted file is | ||
| 52 | - | // logged and the caller keeps its current configuration. | 20 | + | // logged and the caller keeps its current configuration. It blocks until ctx |
| 53 | - | // | 21 | + | // ends. |
| 54 | - | // It watches the parent directory, so atomic replacements (editors that | |||
| 55 | - | // write a temp file and rename it) are detected. If the directory is missing | |||
| 56 | - | // it retries every two seconds. It blocks until ctx ends. | |||
| 57 | func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error { | 22 | func Watch(ctx context.Context, path string, last Config, out chan<- Config, log *slog.Logger) error { | ||
| 58 | - | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | 23 | + | changed := make(chan struct{}, 1) |
| 59 | - | if err != nil { | 24 | + | wctx, cancel := context.WithCancel(ctx) |
| 60 | - | return err | 25 | + | defer cancel() |
| 61 | - | } | 26 | + | errc := make(chan error, 1) |
| 62 | - | defer unix.Close(fd) | 27 | + | go func() { errc <- fswatch.Watch(wctx, path, debounce, changed) }() |
| 63 | - | 28 | + | ||
| 64 | - | names := map[int32]map[string]bool{} // watch -> file names that matter in its directory | 29 | + | for { |
| 65 | - | var files []string | 30 | + | select { |
| 66 | - | var retry, pending time.Time | 31 | + | case <-ctx.Done(): |
| 67 | - | rewatch := true | 32 | + | return nil |
| 68 | - | buf := make([]byte, 4096) | 33 | + | case err := <-errc: |
| 69 | - | fds := make([]unix.PollFd, 1) | |||
| 70 | - | ||||
| 71 | - | for ctx.Err() == nil { | |||
| 72 | - | if !retry.IsZero() && !time.Now().Before(retry) { | |||
| 73 | - | rewatch = true | |||
| 74 | - | } | |||
| 75 | - | if rewatch { | |||
| 76 | - | rewatch, retry = false, time.Time{} | |||
| 77 | - | for wd := range names { | |||
| 78 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |||
| 79 | - | } | |||
| 80 | - | clear(names) | |||
| 81 | - | files = watchedFiles(path) | |||
| 82 | - | for _, file := range files { | |||
| 83 | - | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), watchMask) | |||
| 84 | - | if errors.Is(err, unix.ENOENT) { | |||
| 85 | - | retry = time.Now().Add(retryEvery) | |||
| 86 | - | continue | |||
| 87 | - | } | |||
| 88 | - | if err != nil { | |||
| 89 | - | return err | |||
| 90 | - | } | |||
| 91 | - | if names[int32(wd)] == nil { | |||
| 92 | - | names[int32(wd)] = map[string]bool{} | |||
| 93 | - | } | |||
| 94 | - | names[int32(wd)][filepath.Base(file)] = true | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | timeout := 200 // ms: also the cadence at which ctx is checked | |||
| 99 | - | if !retry.IsZero() { | |||
| 100 | - | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |||
| 101 | - | } | |||
| 102 | - | if !pending.IsZero() { | |||
| 103 | - | remaining := time.Until(pending) | |||
| 104 | - | if remaining <= 0 { | |||
| 105 | - | pending = time.Time{} | |||
| 106 | - | next, err := Load(path, true) | |||
| 107 | - | switch { | |||
| 108 | - | case errors.Is(err, os.ErrNotExist): | |||
| 109 | - | log.Warn("config file removed; keeping the current config", "path", path) | |||
| 110 | - | case err != nil: | |||
| 111 | - | log.Warn("config reload failed; keeping the current config", "err", err) | |||
| 112 | - | case reflect.DeepEqual(next, last): | |||
| 113 | - | log.Debug("config unchanged") | |||
| 114 | - | default: | |||
| 115 | - | last = next | |||
| 116 | - | select { | |||
| 117 | - | case <-ctx.Done(): | |||
| 118 | - | return nil | |||
| 119 | - | case out <- next: | |||
| 120 | - | } | |||
| 121 | - | } | |||
| 122 | - | continue | |||
| 123 | - | } | |||
| 124 | - | timeout = min(timeout, int(remaining.Milliseconds())+1) | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN} | |||
| 128 | - | if _, err = unix.Poll(fds, timeout); err != nil { | |||
| 129 | - | if err == unix.EINTR { | |||
| 130 | - | continue | |||
| 131 | - | } | |||
| 132 | return err | 34 | return err | ||
| 133 | - | } | 35 | + | case <-changed: |
| 134 | - | if fds[0].Revents&unix.POLLIN == 0 { | 36 | + | } |
| 135 | - | continue | 37 | + | next, err := Load(path, true) |
| 136 | - | } | 38 | + | switch { |
| 137 | - | for { | 39 | + | case errors.Is(err, os.ErrNotExist): |
| 138 | - | n, err := unix.Read(fd, buf) | 40 | + | log.Warn("config file removed; keeping the current config", "path", path) |
| 139 | - | if err == unix.EAGAIN || n == 0 { | 41 | + | case err != nil: |
| 140 | - | break | 42 | + | log.Warn("config reload failed; keeping the current config", "err", err) |
| 141 | - | } | 43 | + | case reflect.DeepEqual(next, last): |
| 142 | - | if err == unix.EINTR { | 44 | + | log.Debug("config unchanged") |
| 143 | - | continue | 45 | + | default: |
| 144 | - | } | 46 | + | last = next |
| 145 | - | if err != nil { | 47 | + | select { |
| 146 | - | return err | 48 | + | case <-ctx.Done(): |
| 147 | - | } | 49 | + | return nil |
| 148 | - | for off := 0; off+unix.SizeofInotifyEvent <= n; { | 50 | + | case out <- next: |
| 149 | - | ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off])) | |||
| 150 | - | size := unix.SizeofInotifyEvent + int(ev.Len) | |||
| 151 | - | if off+size > n { | |||
| 152 | - | break | |||
| 153 | - | } | |||
| 154 | - | // One of our watches vanished (its directory was removed). | |||
| 155 | - | if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil { | |||
| 156 | - | rewatch = true | |||
| 157 | - | } | |||
| 158 | - | if ev.Len > 0 { | |||
| 159 | - | name := buf[off+unix.SizeofInotifyEvent : off+size] | |||
| 160 | - | for len(name) > 0 && name[len(name)-1] == 0 { | |||
| 161 | - | name = name[:len(name)-1] | |||
| 162 | - | } | |||
| 163 | - | if names[ev.Wd][string(name)] && ev.Mask&watchMask != 0 { | |||
| 164 | - | pending = time.Now().Add(debounce) | |||
| 165 | - | if !slices.Equal(files, watchedFiles(path)) { | |||
| 166 | - | rewatch = true // a symlink now points elsewhere | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | off += size | |||
| 171 | } | 51 | } | ||
| 172 | } | 52 | } | ||
| 173 | } | 53 | } | ||
| 174 | - | return nil | |||
| 175 | } | 54 | } | ||
A internal/fswatch/fswatch.go +162 -0
| @@ -0,0 +1,162 @@ | |||
| 1 | + | // Package fswatch reports changes to one file with inotify. | |
| 2 | + | // | |
| 3 | + | // It watches the parent directory, so a file that is replaced by a rename (the | |
| 4 | + | // way editors and NeferWL write) is noticed, and it follows symlinks, so a | |
| 5 | + | // dotfile-managed file is noticed at its target. | |
| 6 | + | package fswatch | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "errors" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "slices" | |
| 14 | + | "time" | |
| 15 | + | "unsafe" | |
| 16 | + | ||
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | // retryEvery is how often a missing directory is looked for again. | |
| 22 | + | retryEvery = 2 * time.Second | |
| 23 | + | // pollEvery bounds how long a wait lasts, so ctx is noticed. | |
| 24 | + | pollEvery = 200 // ms | |
| 25 | + | // mask is every way a file can be written, replaced or removed. IN_MODIFY | |
| 26 | + | // restarts the debounce on each write, so a truncating in-place save is | |
| 27 | + | // never read half written. | |
| 28 | + | mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // files lists path and every symlink target it goes through. | |
| 32 | + | func files(path string) []string { | |
| 33 | + | out := []string{filepath.Clean(path)} | |
| 34 | + | for len(out) <= 40 { // the kernel's symlink limit | |
| 35 | + | cur := out[len(out)-1] | |
| 36 | + | target, err := os.Readlink(cur) | |
| 37 | + | if err != nil { | |
| 38 | + | break | |
| 39 | + | } | |
| 40 | + | if !filepath.IsAbs(target) { | |
| 41 | + | target = filepath.Join(filepath.Dir(cur), target) | |
| 42 | + | } | |
| 43 | + | target = filepath.Clean(target) | |
| 44 | + | if slices.Contains(out, target) { | |
| 45 | + | break | |
| 46 | + | } | |
| 47 | + | out = append(out, target) | |
| 48 | + | } | |
| 49 | + | return out | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Watch sends on out after path changed and stayed quiet for debounce. A | |
| 53 | + | // debounce of 0 reports each change at once. The send never blocks: if out is | |
| 54 | + | // full, the pending signal already says "something changed", which is all a | |
| 55 | + | // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends. | |
| 56 | + | func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error { | |
| 57 | + | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 58 | + | if err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | defer unix.Close(fd) | |
| 62 | + | ||
| 63 | + | names := map[int32]map[string]bool{} // watch -> file names that matter in its directory | |
| 64 | + | var watched []string | |
| 65 | + | var retry, pending time.Time | |
| 66 | + | rewatch := true | |
| 67 | + | buf := make([]byte, 4096) | |
| 68 | + | fds := make([]unix.PollFd, 1) | |
| 69 | + | ||
| 70 | + | for ctx.Err() == nil { | |
| 71 | + | if !retry.IsZero() && !time.Now().Before(retry) { | |
| 72 | + | rewatch = true | |
| 73 | + | } | |
| 74 | + | if rewatch { | |
| 75 | + | rewatch, retry = false, time.Time{} | |
| 76 | + | for wd := range names { | |
| 77 | + | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 78 | + | } | |
| 79 | + | clear(names) | |
| 80 | + | watched = files(path) | |
| 81 | + | for _, file := range watched { | |
| 82 | + | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | |
| 83 | + | if errors.Is(err, unix.ENOENT) { | |
| 84 | + | retry = time.Now().Add(retryEvery) | |
| 85 | + | continue | |
| 86 | + | } | |
| 87 | + | if err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | if names[int32(wd)] == nil { | |
| 91 | + | names[int32(wd)] = map[string]bool{} | |
| 92 | + | } | |
| 93 | + | names[int32(wd)][filepath.Base(file)] = true | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | timeout := pollEvery | |
| 98 | + | if !retry.IsZero() { | |
| 99 | + | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |
| 100 | + | } | |
| 101 | + | if !pending.IsZero() { | |
| 102 | + | remaining := time.Until(pending) | |
| 103 | + | if remaining <= 0 { | |
| 104 | + | pending = time.Time{} | |
| 105 | + | select { | |
| 106 | + | case out <- struct{}{}: | |
| 107 | + | default: | |
| 108 | + | } | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | timeout = min(timeout, int(remaining.Milliseconds())+1) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN} | |
| 115 | + | if _, err = unix.Poll(fds, timeout); err != nil { | |
| 116 | + | if err == unix.EINTR { | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | if fds[0].Revents&unix.POLLIN == 0 { | |
| 122 | + | continue | |
| 123 | + | } | |
| 124 | + | for { | |
| 125 | + | n, err := unix.Read(fd, buf) | |
| 126 | + | if err == unix.EAGAIN || n == 0 { | |
| 127 | + | break | |
| 128 | + | } | |
| 129 | + | if err == unix.EINTR { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | if err != nil { | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | for off := 0; off+unix.SizeofInotifyEvent <= n; { | |
| 136 | + | ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off])) | |
| 137 | + | size := unix.SizeofInotifyEvent + int(ev.Len) | |
| 138 | + | if off+size > n { | |
| 139 | + | break | |
| 140 | + | } | |
| 141 | + | // One of our watches vanished (its directory was removed). | |
| 142 | + | if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil { | |
| 143 | + | rewatch = true | |
| 144 | + | } | |
| 145 | + | if ev.Len > 0 { | |
| 146 | + | name := buf[off+unix.SizeofInotifyEvent : off+size] | |
| 147 | + | for len(name) > 0 && name[len(name)-1] == 0 { | |
| 148 | + | name = name[:len(name)-1] | |
| 149 | + | } | |
| 150 | + | if names[ev.Wd][string(name)] && ev.Mask&mask != 0 { | |
| 151 | + | pending = time.Now().Add(debounce) | |
| 152 | + | if !slices.Equal(watched, files(path)) { | |
| 153 | + | rewatch = true // a symlink now points elsewhere | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | off += size | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | } | |
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | // Package fswatch reports changes to one file with inotify. | |||
| 2 | + | // | |||
| 3 | + | // It watches the parent directory, so a file that is replaced by a rename (the | |||
| 4 | + | // way editors and NeferWL write) is noticed, and it follows symlinks, so a | |||
| 5 | + | // dotfile-managed file is noticed at its target. | |||
| 6 | + | package fswatch | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | "errors" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "slices" | |||
| 14 | + | "time" | |||
| 15 | + | "unsafe" | |||
| 16 | + | ||||
| 17 | + | "golang.org/x/sys/unix" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | // retryEvery is how often a missing directory is looked for again. | |||
| 22 | + | retryEvery = 2 * time.Second | |||
| 23 | + | // pollEvery bounds how long a wait lasts, so ctx is noticed. | |||
| 24 | + | pollEvery = 200 // ms | |||
| 25 | + | // mask is every way a file can be written, replaced or removed. IN_MODIFY | |||
| 26 | + | // restarts the debounce on each write, so a truncating in-place save is | |||
| 27 | + | // never read half written. | |||
| 28 | + | mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // files lists path and every symlink target it goes through. | |||
| 32 | + | func files(path string) []string { | |||
| 33 | + | out := []string{filepath.Clean(path)} | |||
| 34 | + | for len(out) <= 40 { // the kernel's symlink limit | |||
| 35 | + | cur := out[len(out)-1] | |||
| 36 | + | target, err := os.Readlink(cur) | |||
| 37 | + | if err != nil { | |||
| 38 | + | break | |||
| 39 | + | } | |||
| 40 | + | if !filepath.IsAbs(target) { | |||
| 41 | + | target = filepath.Join(filepath.Dir(cur), target) | |||
| 42 | + | } | |||
| 43 | + | target = filepath.Clean(target) | |||
| 44 | + | if slices.Contains(out, target) { | |||
| 45 | + | break | |||
| 46 | + | } | |||
| 47 | + | out = append(out, target) | |||
| 48 | + | } | |||
| 49 | + | return out | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Watch sends on out after path changed and stayed quiet for debounce. A | |||
| 53 | + | // debounce of 0 reports each change at once. The send never blocks: if out is | |||
| 54 | + | // full, the pending signal already says "something changed", which is all a | |||
| 55 | + | // reader needs. Use a channel with a buffer of 1. Watch blocks until ctx ends. | |||
| 56 | + | func Watch(ctx context.Context, path string, debounce time.Duration, out chan<- struct{}) error { | |||
| 57 | + | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | defer unix.Close(fd) | |||
| 62 | + | ||||
| 63 | + | names := map[int32]map[string]bool{} // watch -> file names that matter in its directory | |||
| 64 | + | var watched []string | |||
| 65 | + | var retry, pending time.Time | |||
| 66 | + | rewatch := true | |||
| 67 | + | buf := make([]byte, 4096) | |||
| 68 | + | fds := make([]unix.PollFd, 1) | |||
| 69 | + | ||||
| 70 | + | for ctx.Err() == nil { | |||
| 71 | + | if !retry.IsZero() && !time.Now().Before(retry) { | |||
| 72 | + | rewatch = true | |||
| 73 | + | } | |||
| 74 | + | if rewatch { | |||
| 75 | + | rewatch, retry = false, time.Time{} | |||
| 76 | + | for wd := range names { | |||
| 77 | + | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |||
| 78 | + | } | |||
| 79 | + | clear(names) | |||
| 80 | + | watched = files(path) | |||
| 81 | + | for _, file := range watched { | |||
| 82 | + | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | |||
| 83 | + | if errors.Is(err, unix.ENOENT) { | |||
| 84 | + | retry = time.Now().Add(retryEvery) | |||
| 85 | + | continue | |||
| 86 | + | } | |||
| 87 | + | if err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | if names[int32(wd)] == nil { | |||
| 91 | + | names[int32(wd)] = map[string]bool{} | |||
| 92 | + | } | |||
| 93 | + | names[int32(wd)][filepath.Base(file)] = true | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | timeout := pollEvery | |||
| 98 | + | if !retry.IsZero() { | |||
| 99 | + | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |||
| 100 | + | } | |||
| 101 | + | if !pending.IsZero() { | |||
| 102 | + | remaining := time.Until(pending) | |||
| 103 | + | if remaining <= 0 { | |||
| 104 | + | pending = time.Time{} | |||
| 105 | + | select { | |||
| 106 | + | case out <- struct{}{}: | |||
| 107 | + | default: | |||
| 108 | + | } | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | timeout = min(timeout, int(remaining.Milliseconds())+1) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | fds[0] = unix.PollFd{Fd: int32(fd), Events: unix.POLLIN} | |||
| 115 | + | if _, err = unix.Poll(fds, timeout); err != nil { | |||
| 116 | + | if err == unix.EINTR { | |||
| 117 | + | continue | |||
| 118 | + | } | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | if fds[0].Revents&unix.POLLIN == 0 { | |||
| 122 | + | continue | |||
| 123 | + | } | |||
| 124 | + | for { | |||
| 125 | + | n, err := unix.Read(fd, buf) | |||
| 126 | + | if err == unix.EAGAIN || n == 0 { | |||
| 127 | + | break | |||
| 128 | + | } | |||
| 129 | + | if err == unix.EINTR { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | if err != nil { | |||
| 133 | + | return err | |||
| 134 | + | } | |||
| 135 | + | for off := 0; off+unix.SizeofInotifyEvent <= n; { | |||
| 136 | + | ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off])) | |||
| 137 | + | size := unix.SizeofInotifyEvent + int(ev.Len) | |||
| 138 | + | if off+size > n { | |||
| 139 | + | break | |||
| 140 | + | } | |||
| 141 | + | // One of our watches vanished (its directory was removed). | |||
| 142 | + | if ev.Mask&unix.IN_IGNORED != 0 && names[ev.Wd] != nil { | |||
| 143 | + | rewatch = true | |||
| 144 | + | } | |||
| 145 | + | if ev.Len > 0 { | |||
| 146 | + | name := buf[off+unix.SizeofInotifyEvent : off+size] | |||
| 147 | + | for len(name) > 0 && name[len(name)-1] == 0 { | |||
| 148 | + | name = name[:len(name)-1] | |||
| 149 | + | } | |||
| 150 | + | if names[ev.Wd][string(name)] && ev.Mask&mask != 0 { | |||
| 151 | + | pending = time.Now().Add(debounce) | |||
| 152 | + | if !slices.Equal(watched, files(path)) { | |||
| 153 | + | rewatch = true // a symlink now points elsewhere | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | off += size | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | } | |||