fix(config): reload a symlinked config when its target changes (#23)
* fix(config): reload a symlinked config when its target changes Watch also watches the directory of the file the config resolves to, and moves that watch when the link is retargeted, so dotfiles symlinks reload on edits and atomic saves. * fix(config): watch every link of a symlinked config and retry missing targets The watcher follows the whole symlink chain, keeps watching a dangling target, and sets its watches up again while a directory is missing. * test(config): check retargeted links and recreated directories without retried setup The retarget and directory-recreation steps now happen once, so the test fails if the watcher does not move or restore its watches.
- commit
- 4e1cc2ae22e3562956c6baa3f37b0449891d4552
- author
- bnema · · 2026-09-27 14:16 UTC
- committer
- GitHub · · 2026-09-27 14:16 UTC
- parent
- 509f0eb
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +204 -19
M internal/adapters/config/watch.go +68 -19
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "slices" | |
| 8 | 9 | "sort" | |
| 9 | 10 | "time" | |
| 10 | 11 | "unsafe" | |
| @@ -51,10 +52,35 @@ func diff(old, cur map[string]string) []string { | |||
| 51 | 52 | return keys | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 55 | + | // watchedFiles are the files whose changes reload the config: path, then | |
| 56 | + | // each symlink it goes through (dotfiles) down to the final file, which may | |
| 57 | + | // not exist yet. Symlinked parent directories are followed by inotify | |
| 58 | + | // itself; retargeting one is not detected. | |
| 59 | + | func watchedFiles(path string) []string { | |
| 60 | + | files := []string{filepath.Clean(path)} | |
| 61 | + | for len(files) <= 40 { // the kernel's symlink limit | |
| 62 | + | cur := files[len(files)-1] | |
| 63 | + | target, err := os.Readlink(cur) | |
| 64 | + | if err != nil { | |
| 65 | + | break | |
| 66 | + | } | |
| 67 | + | if !filepath.IsAbs(target) { | |
| 68 | + | target = filepath.Join(filepath.Dir(cur), target) | |
| 69 | + | } | |
| 70 | + | target = filepath.Clean(target) | |
| 71 | + | if slices.Contains(files, target) { | |
| 72 | + | break // a loop: the config is unreadable anyway | |
| 73 | + | } | |
| 74 | + | files = append(files, target) | |
| 75 | + | } | |
| 76 | + | return files | |
| 77 | + | } | |
| 78 | + | ||
| 54 | 79 | // Watch observes the parent directory so atomic file replacements are detected. | |
| 55 | - | // If the directory is absent, it retries every two seconds until it appears. | |
| 80 | + | // A symlinked config is also watched at its target, and a retargeted link | |
| 81 | + | // moves that watch. If the directory is absent, it retries every two seconds | |
| 82 | + | // until it appears. | |
| 56 | 83 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error { | |
| 57 | - | dir, name := filepath.Dir(path), filepath.Base(path) | |
| 58 | 84 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 59 | 85 | if err != nil { | |
| 60 | 86 | return err | |
| @@ -63,7 +89,12 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||
| 63 | 89 | // IN_MODIFY restarts the debounce on every write, the truncation of an | |
| 64 | 90 | // in-place save included: the file is never read half written. | |
| 65 | 91 | const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | |
| 66 | - | wd := -1 | |
| 92 | + | // names maps each watch to the file names that matter in its directory. | |
| 93 | + | names := map[int32]map[string]bool{} | |
| 94 | + | var files []string | |
| 95 | + | rewatch := true | |
| 96 | + | // retry sets the watches up again while a directory is missing. | |
| 97 | + | var retry time.Time | |
| 67 | 98 | var pending time.Time | |
| 68 | 99 | last := loadRaw(path) | |
| 69 | 100 | buf := make([]byte, 4096) | |
| @@ -71,21 +102,35 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||
| 71 | 102 | if ctx.Err() != nil { | |
| 72 | 103 | return nil | |
| 73 | 104 | } | |
| 74 | - | if wd < 0 { | |
| 75 | - | wd, err = unix.InotifyAddWatch(fd, dir, mask) | |
| 76 | - | if errors.Is(err, unix.ENOENT) { | |
| 77 | - | select { | |
| 78 | - | case <-ctx.Done(): | |
| 79 | - | return nil | |
| 80 | - | case <-time.After(directoryPollInterval): | |
| 105 | + | if !retry.IsZero() && !time.Now().Before(retry) { | |
| 106 | + | rewatch = true | |
| 107 | + | } | |
| 108 | + | if rewatch { | |
| 109 | + | rewatch, retry = false, time.Time{} | |
| 110 | + | for wd := range names { | |
| 111 | + | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 112 | + | } | |
| 113 | + | clear(names) | |
| 114 | + | files = watchedFiles(path) | |
| 115 | + | for _, file := range files { | |
| 116 | + | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | |
| 117 | + | if errors.Is(err, unix.ENOENT) { | |
| 118 | + | retry = time.Now().Add(directoryPollInterval) | |
| 81 | 119 | continue | |
| 82 | 120 | } | |
| 83 | - | } | |
| 84 | - | if err != nil { | |
| 85 | - | return err | |
| 121 | + | if err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | if names[int32(wd)] == nil { | |
| 125 | + | names[int32(wd)] = map[string]bool{} | |
| 126 | + | } | |
| 127 | + | names[int32(wd)][filepath.Base(file)] = true | |
| 86 | 128 | } | |
| 87 | 129 | } | |
| 88 | 130 | timeout := 200 | |
| 131 | + | if !retry.IsZero() { | |
| 132 | + | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |
| 133 | + | } | |
| 89 | 134 | if !pending.IsZero() { | |
| 90 | 135 | remaining := time.Until(pending) | |
| 91 | 136 | if remaining <= 0 { | |
| @@ -115,9 +160,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||
| 115 | 160 | } | |
| 116 | 161 | continue | |
| 117 | 162 | } | |
| 118 | - | if remaining < 200*time.Millisecond { | |
| 119 | - | timeout = int(remaining.Milliseconds()) + 1 | |
| 120 | - | } | |
| 163 | + | timeout = min(timeout, int(remaining.Milliseconds())+1) | |
| 121 | 164 | } | |
| 122 | 165 | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 123 | 166 | _, err = unix.Poll(fds, timeout) | |
| @@ -150,16 +193,22 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||
| 150 | 193 | if offset+size > n { | |
| 151 | 194 | break | |
| 152 | 195 | } | |
| 153 | - | if event.Mask&unix.IN_IGNORED != 0 { | |
| 154 | - | wd = -1 | |
| 196 | + | // A removed watch of ours (its directory is gone) is set up | |
| 197 | + | // again; removals we asked for are no longer in names. | |
| 198 | + | if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil { | |
| 199 | + | rewatch = true | |
| 155 | 200 | } | |
| 156 | 201 | if event.Len > 0 { | |
| 157 | 202 | file := buf[offset+unix.SizeofInotifyEvent : offset+size] | |
| 158 | 203 | for len(file) > 0 && file[len(file)-1] == 0 { | |
| 159 | 204 | file = file[:len(file)-1] | |
| 160 | 205 | } | |
| 161 | - | if string(file) == name && event.Mask&mask != 0 { | |
| 206 | + | if names[event.Wd][string(file)] && event.Mask&mask != 0 { | |
| 162 | 207 | pending = time.Now().Add(100 * time.Millisecond) | |
| 208 | + | // The link may point elsewhere now. | |
| 209 | + | if !slices.Equal(files, watchedFiles(path)) { | |
| 210 | + | rewatch = true | |
| 211 | + | } | |
| 163 | 212 | } | |
| 164 | 213 | } | |
| 165 | 214 | offset += size | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "slices" | |||
| 8 | "sort" | 9 | "sort" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | "unsafe" | 11 | "unsafe" | ||
| @@ -51,10 +52,35 @@ func diff(old, cur map[string]string) []string { | |||||
| 51 | return keys | 52 | return keys | ||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
| 55 | + | // watchedFiles are the files whose changes reload the config: path, then | |||
| 56 | + | // each symlink it goes through (dotfiles) down to the final file, which may | |||
| 57 | + | // not exist yet. Symlinked parent directories are followed by inotify | |||
| 58 | + | // itself; retargeting one is not detected. | |||
| 59 | + | func watchedFiles(path string) []string { | |||
| 60 | + | files := []string{filepath.Clean(path)} | |||
| 61 | + | for len(files) <= 40 { // the kernel's symlink limit | |||
| 62 | + | cur := files[len(files)-1] | |||
| 63 | + | target, err := os.Readlink(cur) | |||
| 64 | + | if err != nil { | |||
| 65 | + | break | |||
| 66 | + | } | |||
| 67 | + | if !filepath.IsAbs(target) { | |||
| 68 | + | target = filepath.Join(filepath.Dir(cur), target) | |||
| 69 | + | } | |||
| 70 | + | target = filepath.Clean(target) | |||
| 71 | + | if slices.Contains(files, target) { | |||
| 72 | + | break // a loop: the config is unreadable anyway | |||
| 73 | + | } | |||
| 74 | + | files = append(files, target) | |||
| 75 | + | } | |||
| 76 | + | return files | |||
| 77 | + | } | |||
| 78 | + | ||||
| 54 | // Watch observes the parent directory so atomic file replacements are detected. | 79 | // Watch observes the parent directory so atomic file replacements are detected. | ||
| 55 | - | // If the directory is absent, it retries every two seconds until it appears. | 80 | + | // A symlinked config is also watched at its target, and a retargeted link |
| 81 | + | // moves that watch. If the directory is absent, it retries every two seconds | |||
| 82 | + | // until it appears. | |||
| 56 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error { | 83 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error { | ||
| 57 | - | dir, name := filepath.Dir(path), filepath.Base(path) | |||
| 58 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | 84 | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | ||
| 59 | if err != nil { | 85 | if err != nil { | ||
| 60 | return err | 86 | return err | ||
| @@ -63,7 +89,12 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||||
| 63 | // IN_MODIFY restarts the debounce on every write, the truncation of an | 89 | // IN_MODIFY restarts the debounce on every write, the truncation of an | ||
| 64 | // in-place save included: the file is never read half written. | 90 | // in-place save included: the file is never read half written. | ||
| 65 | const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | 91 | const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | ||
| 66 | - | wd := -1 | 92 | + | // names maps each watch to the file names that matter in its directory. |
| 93 | + | names := map[int32]map[string]bool{} | |||
| 94 | + | var files []string | |||
| 95 | + | rewatch := true | |||
| 96 | + | // retry sets the watches up again while a directory is missing. | |||
| 97 | + | var retry time.Time | |||
| 67 | var pending time.Time | 98 | var pending time.Time | ||
| 68 | last := loadRaw(path) | 99 | last := loadRaw(path) | ||
| 69 | buf := make([]byte, 4096) | 100 | buf := make([]byte, 4096) | ||
| @@ -71,21 +102,35 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||||
| 71 | if ctx.Err() != nil { | 102 | if ctx.Err() != nil { | ||
| 72 | return nil | 103 | return nil | ||
| 73 | } | 104 | } | ||
| 74 | - | if wd < 0 { | 105 | + | if !retry.IsZero() && !time.Now().Before(retry) { |
| 75 | - | wd, err = unix.InotifyAddWatch(fd, dir, mask) | 106 | + | rewatch = true |
| 76 | - | if errors.Is(err, unix.ENOENT) { | 107 | + | } |
| 77 | - | select { | 108 | + | if rewatch { |
| 78 | - | case <-ctx.Done(): | 109 | + | rewatch, retry = false, time.Time{} |
| 79 | - | return nil | 110 | + | for wd := range names { |
| 80 | - | case <-time.After(directoryPollInterval): | 111 | + | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) |
| 112 | + | } | |||
| 113 | + | clear(names) | |||
| 114 | + | files = watchedFiles(path) | |||
| 115 | + | for _, file := range files { | |||
| 116 | + | wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask) | |||
| 117 | + | if errors.Is(err, unix.ENOENT) { | |||
| 118 | + | retry = time.Now().Add(directoryPollInterval) | |||
| 81 | continue | 119 | continue | ||
| 82 | } | 120 | } | ||
| 83 | - | } | 121 | + | if err != nil { |
| 84 | - | if err != nil { | 122 | + | return err |
| 85 | - | return err | 123 | + | } |
| 124 | + | if names[int32(wd)] == nil { | |||
| 125 | + | names[int32(wd)] = map[string]bool{} | |||
| 126 | + | } | |||
| 127 | + | names[int32(wd)][filepath.Base(file)] = true | |||
| 86 | } | 128 | } | ||
| 87 | } | 129 | } | ||
| 88 | timeout := 200 | 130 | timeout := 200 | ||
| 131 | + | if !retry.IsZero() { | |||
| 132 | + | timeout = min(timeout, int(time.Until(retry).Milliseconds())+1) | |||
| 133 | + | } | |||
| 89 | if !pending.IsZero() { | 134 | if !pending.IsZero() { | ||
| 90 | remaining := time.Until(pending) | 135 | remaining := time.Until(pending) | ||
| 91 | if remaining <= 0 { | 136 | if remaining <= 0 { | ||
| @@ -115,9 +160,7 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||||
| 115 | } | 160 | } | ||
| 116 | continue | 161 | continue | ||
| 117 | } | 162 | } | ||
| 118 | - | if remaining < 200*time.Millisecond { | 163 | + | timeout = min(timeout, int(remaining.Milliseconds())+1) |
| 119 | - | timeout = int(remaining.Milliseconds()) + 1 | |||
| 120 | - | } | |||
| 121 | } | 164 | } | ||
| 122 | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | 165 | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | ||
| 123 | _, err = unix.Poll(fds, timeout) | 166 | _, err = unix.Poll(fds, timeout) | ||
| @@ -150,16 +193,22 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log | |||||
| 150 | if offset+size > n { | 193 | if offset+size > n { | ||
| 151 | break | 194 | break | ||
| 152 | } | 195 | } | ||
| 153 | - | if event.Mask&unix.IN_IGNORED != 0 { | 196 | + | // A removed watch of ours (its directory is gone) is set up |
| 154 | - | wd = -1 | 197 | + | // again; removals we asked for are no longer in names. |
| 198 | + | if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil { | |||
| 199 | + | rewatch = true | |||
| 155 | } | 200 | } | ||
| 156 | if event.Len > 0 { | 201 | if event.Len > 0 { | ||
| 157 | file := buf[offset+unix.SizeofInotifyEvent : offset+size] | 202 | file := buf[offset+unix.SizeofInotifyEvent : offset+size] | ||
| 158 | for len(file) > 0 && file[len(file)-1] == 0 { | 203 | for len(file) > 0 && file[len(file)-1] == 0 { | ||
| 159 | file = file[:len(file)-1] | 204 | file = file[:len(file)-1] | ||
| 160 | } | 205 | } | ||
| 161 | - | if string(file) == name && event.Mask&mask != 0 { | 206 | + | if names[event.Wd][string(file)] && event.Mask&mask != 0 { |
| 162 | pending = time.Now().Add(100 * time.Millisecond) | 207 | pending = time.Now().Add(100 * time.Millisecond) | ||
| 208 | + | // The link may point elsewhere now. | |||
| 209 | + | if !slices.Equal(files, watchedFiles(path)) { | |||
| 210 | + | rewatch = true | |||
| 211 | + | } | |||
| 163 | } | 212 | } | ||
| 164 | } | 213 | } | ||
| 165 | offset += size | 214 | offset += size | ||
M internal/adapters/config/watch_test.go +136 -0
| @@ -2,8 +2,10 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 8 | + | "slices" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -112,3 +114,137 @@ func TestWatch(t *testing.T) { | |||
| 112 | 114 | }) | |
| 113 | 115 | } | |
| 114 | 116 | } | |
| 117 | + | ||
| 118 | + | // A config symlinked elsewhere (dotfiles), directly or through a chain, | |
| 119 | + | // reloads when its target changes, follows a retargeted link, and survives | |
| 120 | + | // a target that is missing for a while. | |
| 121 | + | func TestWatchSymlink(t *testing.T) { | |
| 122 | + | root := t.TempDir() | |
| 123 | + | dirs := map[string]string{} | |
| 124 | + | for _, d := range []string{"config", "dotfiles", "chain", "other"} { | |
| 125 | + | dirs[d] = filepath.Join(root, d) | |
| 126 | + | if err := os.Mkdir(dirs[d], 0700); err != nil { | |
| 127 | + | t.Fatal(err) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | write := func(file, body string) { | |
| 131 | + | t.Helper() | |
| 132 | + | if err := os.WriteFile(file, []byte(body), 0600); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | link := func(target, at string) { | |
| 137 | + | t.Helper() | |
| 138 | + | tmp := at + ".tmp" | |
| 139 | + | if err := os.Symlink(target, tmp); err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | if err := os.Rename(tmp, at); err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | // config/config -> chain/config (relative) -> dotfiles/neferwl.conf | |
| 147 | + | target := filepath.Join(dirs["dotfiles"], "neferwl.conf") | |
| 148 | + | write(target, "background = #000000\n") | |
| 149 | + | middle := filepath.Join(dirs["chain"], "config") | |
| 150 | + | link("../dotfiles/neferwl.conf", middle) | |
| 151 | + | path := filepath.Join(dirs["config"], "config") | |
| 152 | + | link(middle, path) | |
| 153 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 154 | + | out := make(chan ports.ConfigChanged, 8) | |
| 155 | + | done := make(chan error, 1) | |
| 156 | + | old := directoryPollInterval | |
| 157 | + | directoryPollInterval = 20 * time.Millisecond | |
| 158 | + | defer func() { directoryPollInterval = old }() | |
| 159 | + | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |
| 160 | + | defer func() { | |
| 161 | + | cancel() | |
| 162 | + | if err := <-done; err != nil { | |
| 163 | + | t.Error(err) | |
| 164 | + | } | |
| 165 | + | }() | |
| 166 | + | // until repeats change until the config reloads to want: the watches | |
| 167 | + | // may not be set up yet on a slow machine. Each try changes another key | |
| 168 | + | // too, as the watcher may have read the first try at startup. | |
| 169 | + | tries := 0 | |
| 170 | + | until := func(want string, change func(body string)) { | |
| 171 | + | t.Helper() | |
| 172 | + | try := func() { | |
| 173 | + | tries++ | |
| 174 | + | change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32)) | |
| 175 | + | } | |
| 176 | + | try() | |
| 177 | + | deadline := time.After(2 * time.Second) | |
| 178 | + | retry := time.NewTicker(300 * time.Millisecond) | |
| 179 | + | defer retry.Stop() | |
| 180 | + | for { | |
| 181 | + | select { | |
| 182 | + | case c := <-out: | |
| 183 | + | if c.Config.Background.Color == want { | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | case <-retry.C: | |
| 187 | + | try() | |
| 188 | + | case <-deadline: | |
| 189 | + | t.Fatalf("no config change to %s", want) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | atomic := func(file string) func(string) { | |
| 194 | + | return func(body string) { | |
| 195 | + | write(file+".new", body) | |
| 196 | + | if err := os.Rename(file+".new", file); err != nil { | |
| 197 | + | t.Fatal(err) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | until("#ff0000", func(body string) { write(target, body) }) | |
| 202 | + | until("#00ff00", atomic(target)) | |
| 203 | + | // The middle link now points to another file, once: later writes to it | |
| 204 | + | // reload only if the watch moved there. | |
| 205 | + | moved := filepath.Join(dirs["other"], "config") | |
| 206 | + | write(moved, "background = #0000ff\n") | |
| 207 | + | link(moved, middle) | |
| 208 | + | until("#0000ff", func(string) {}) | |
| 209 | + | until("#ffffff", func(body string) { write(moved, body) }) | |
| 210 | + | // The target disappears with its directory, then comes back once: later | |
| 211 | + | // writes reload only if the missing directory was watched again. | |
| 212 | + | if err := os.RemoveAll(dirs["other"]); err != nil { | |
| 213 | + | t.Fatal(err) | |
| 214 | + | } | |
| 215 | + | time.Sleep(50 * time.Millisecond) | |
| 216 | + | if err := os.Mkdir(dirs["other"], 0700); err != nil { | |
| 217 | + | t.Fatal(err) | |
| 218 | + | } | |
| 219 | + | write(moved, "background = #000001\n") | |
| 220 | + | time.Sleep(100 * time.Millisecond) // past the 20 ms retry | |
| 221 | + | until("#abcdef", func(body string) { write(moved, body) }) | |
| 222 | + | // The old target no longer matters. | |
| 223 | + | write(target, "background = #123456\n") | |
| 224 | + | select { | |
| 225 | + | case c := <-out: | |
| 226 | + | t.Fatalf("old target reloaded: %+v", c.Config.Background) | |
| 227 | + | case <-time.After(250 * time.Millisecond): | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestWatchedFiles(t *testing.T) { | |
| 232 | + | dir := t.TempDir() | |
| 233 | + | path := filepath.Join(dir, "config") | |
| 234 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path}) { | |
| 235 | + | t.Fatal(got) | |
| 236 | + | } | |
| 237 | + | // A dangling link still names its target; a loop stops. | |
| 238 | + | if err := os.Symlink("sub/../missing", path); err != nil { | |
| 239 | + | t.Fatal(err) | |
| 240 | + | } | |
| 241 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) { | |
| 242 | + | t.Fatal(got) | |
| 243 | + | } | |
| 244 | + | if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil { | |
| 245 | + | t.Fatal(err) | |
| 246 | + | } | |
| 247 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) { | |
| 248 | + | t.Fatal(got) | |
| 249 | + | } | |
| 250 | + | } | |
| @@ -2,8 +2,10 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "slices" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -112,3 +114,137 @@ func TestWatch(t *testing.T) { | |||||
| 112 | }) | 114 | }) | ||
| 113 | } | 115 | } | ||
| 114 | } | 116 | } | ||
| 117 | + | ||||
| 118 | + | // A config symlinked elsewhere (dotfiles), directly or through a chain, | |||
| 119 | + | // reloads when its target changes, follows a retargeted link, and survives | |||
| 120 | + | // a target that is missing for a while. | |||
| 121 | + | func TestWatchSymlink(t *testing.T) { | |||
| 122 | + | root := t.TempDir() | |||
| 123 | + | dirs := map[string]string{} | |||
| 124 | + | for _, d := range []string{"config", "dotfiles", "chain", "other"} { | |||
| 125 | + | dirs[d] = filepath.Join(root, d) | |||
| 126 | + | if err := os.Mkdir(dirs[d], 0700); err != nil { | |||
| 127 | + | t.Fatal(err) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | write := func(file, body string) { | |||
| 131 | + | t.Helper() | |||
| 132 | + | if err := os.WriteFile(file, []byte(body), 0600); err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | link := func(target, at string) { | |||
| 137 | + | t.Helper() | |||
| 138 | + | tmp := at + ".tmp" | |||
| 139 | + | if err := os.Symlink(target, tmp); err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | if err := os.Rename(tmp, at); err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | // config/config -> chain/config (relative) -> dotfiles/neferwl.conf | |||
| 147 | + | target := filepath.Join(dirs["dotfiles"], "neferwl.conf") | |||
| 148 | + | write(target, "background = #000000\n") | |||
| 149 | + | middle := filepath.Join(dirs["chain"], "config") | |||
| 150 | + | link("../dotfiles/neferwl.conf", middle) | |||
| 151 | + | path := filepath.Join(dirs["config"], "config") | |||
| 152 | + | link(middle, path) | |||
| 153 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 154 | + | out := make(chan ports.ConfigChanged, 8) | |||
| 155 | + | done := make(chan error, 1) | |||
| 156 | + | old := directoryPollInterval | |||
| 157 | + | directoryPollInterval = 20 * time.Millisecond | |||
| 158 | + | defer func() { directoryPollInterval = old }() | |||
| 159 | + | go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }() | |||
| 160 | + | defer func() { | |||
| 161 | + | cancel() | |||
| 162 | + | if err := <-done; err != nil { | |||
| 163 | + | t.Error(err) | |||
| 164 | + | } | |||
| 165 | + | }() | |||
| 166 | + | // until repeats change until the config reloads to want: the watches | |||
| 167 | + | // may not be set up yet on a slow machine. Each try changes another key | |||
| 168 | + | // too, as the watcher may have read the first try at startup. | |||
| 169 | + | tries := 0 | |||
| 170 | + | until := func(want string, change func(body string)) { | |||
| 171 | + | t.Helper() | |||
| 172 | + | try := func() { | |||
| 173 | + | tries++ | |||
| 174 | + | change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32)) | |||
| 175 | + | } | |||
| 176 | + | try() | |||
| 177 | + | deadline := time.After(2 * time.Second) | |||
| 178 | + | retry := time.NewTicker(300 * time.Millisecond) | |||
| 179 | + | defer retry.Stop() | |||
| 180 | + | for { | |||
| 181 | + | select { | |||
| 182 | + | case c := <-out: | |||
| 183 | + | if c.Config.Background.Color == want { | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | case <-retry.C: | |||
| 187 | + | try() | |||
| 188 | + | case <-deadline: | |||
| 189 | + | t.Fatalf("no config change to %s", want) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | atomic := func(file string) func(string) { | |||
| 194 | + | return func(body string) { | |||
| 195 | + | write(file+".new", body) | |||
| 196 | + | if err := os.Rename(file+".new", file); err != nil { | |||
| 197 | + | t.Fatal(err) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | until("#ff0000", func(body string) { write(target, body) }) | |||
| 202 | + | until("#00ff00", atomic(target)) | |||
| 203 | + | // The middle link now points to another file, once: later writes to it | |||
| 204 | + | // reload only if the watch moved there. | |||
| 205 | + | moved := filepath.Join(dirs["other"], "config") | |||
| 206 | + | write(moved, "background = #0000ff\n") | |||
| 207 | + | link(moved, middle) | |||
| 208 | + | until("#0000ff", func(string) {}) | |||
| 209 | + | until("#ffffff", func(body string) { write(moved, body) }) | |||
| 210 | + | // The target disappears with its directory, then comes back once: later | |||
| 211 | + | // writes reload only if the missing directory was watched again. | |||
| 212 | + | if err := os.RemoveAll(dirs["other"]); err != nil { | |||
| 213 | + | t.Fatal(err) | |||
| 214 | + | } | |||
| 215 | + | time.Sleep(50 * time.Millisecond) | |||
| 216 | + | if err := os.Mkdir(dirs["other"], 0700); err != nil { | |||
| 217 | + | t.Fatal(err) | |||
| 218 | + | } | |||
| 219 | + | write(moved, "background = #000001\n") | |||
| 220 | + | time.Sleep(100 * time.Millisecond) // past the 20 ms retry | |||
| 221 | + | until("#abcdef", func(body string) { write(moved, body) }) | |||
| 222 | + | // The old target no longer matters. | |||
| 223 | + | write(target, "background = #123456\n") | |||
| 224 | + | select { | |||
| 225 | + | case c := <-out: | |||
| 226 | + | t.Fatalf("old target reloaded: %+v", c.Config.Background) | |||
| 227 | + | case <-time.After(250 * time.Millisecond): | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func TestWatchedFiles(t *testing.T) { | |||
| 232 | + | dir := t.TempDir() | |||
| 233 | + | path := filepath.Join(dir, "config") | |||
| 234 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path}) { | |||
| 235 | + | t.Fatal(got) | |||
| 236 | + | } | |||
| 237 | + | // A dangling link still names its target; a loop stops. | |||
| 238 | + | if err := os.Symlink("sub/../missing", path); err != nil { | |||
| 239 | + | t.Fatal(err) | |||
| 240 | + | } | |||
| 241 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) { | |||
| 242 | + | t.Fatal(got) | |||
| 243 | + | } | |||
| 244 | + | if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) { | |||
| 248 | + | t.Fatal(got) | |||
| 249 | + | } | |||
| 250 | + | } | |||