feat(config): reload the config file live, restarting only the modules that changed
- commit
- 41ad4a0f26d3817d7368319e56448b9f85f7b4fa
- author
- brice · · 2026-10-05 14:53 UTC
- parent
- c4a6bed
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +469 -22
M README.md +9 -0
| @@ -38,6 +38,15 @@ zone = "center" # left | center | right | |||
| 38 | 38 | exec = "clock.sh" | |
| 39 | 39 | ``` | |
| 40 | 40 | ||
| 41 | + | ## Live reload | |
| 42 | + | ||
| 43 | + | The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays. | |
| 44 | + | ||
| 45 | + | - Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes. | |
| 46 | + | - Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started. | |
| 47 | + | - `bar.output` needs a restart, and the bar says so. | |
| 48 | + | - A module script that you edit on disk is picked up when the script restarts, not before. | |
| 49 | + | ||
| 41 | 50 | ## Modules | |
| 42 | 51 | ||
| 43 | 52 | A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame. | |
| @@ -38,6 +38,15 @@ zone = "center" # left | center | right | |||||
| 38 | exec = "clock.sh" | 38 | exec = "clock.sh" | ||
| 39 | ``` | 39 | ``` | ||
| 40 | 40 | ||||
| 41 | + | ## Live reload | |||
| 42 | + | ||||
| 43 | + | The config file is watched, including editors that save through a temp file and symlinked dotfiles. A valid change applies at once; a broken, invalid or deleted file is logged and the running config stays. | |||
| 44 | + | ||||
| 45 | + | - Colors, font, size and scale apply live; the bar rebuilds its font and recreates the surface if its height changes. | |||
| 46 | + | - Modules are matched by `name`: an unchanged one keeps running, a removed one is stopped, a new or changed one is started. | |||
| 47 | + | - `bar.output` needs a restart, and the bar says so. | |||
| 48 | + | - A module script that you edit on disk is picked up when the script restarts, not before. | |||
| 49 | + | ||||
| 41 | ## Modules | 50 | ## Modules | ||
| 42 | 51 | ||||
| 43 | A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame. | 52 | A module is a long-running `/bin/sh -c` command. Its stdout is ANSI text (SGR colors, bold, inverse, dim). A frame ends at a newline or a form feed, and each frame replaces the previous one. Only the latest frame matters: a script that prints faster than the bar draws overwrites its pending frame. | ||
M cmd/neferbar/main.go +1 -0
| @@ -53,6 +53,7 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri | |||
| 53 | 53 | if err != nil { | |
| 54 | 54 | return err | |
| 55 | 55 | } | |
| 56 | + | b.WatchConfig(cfgPath) | |
| 56 | 57 | if memStats > 0 { | |
| 57 | 58 | go logMemStats(ctx, log, memStats) | |
| 58 | 59 | } | |
| @@ -53,6 +53,7 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri | |||||
| 53 | if err != nil { | 53 | if err != nil { | ||
| 54 | return err | 54 | return err | ||
| 55 | } | 55 | } | ||
| 56 | + | b.WatchConfig(cfgPath) | |||
| 56 | if memStats > 0 { | 57 | if memStats > 0 { | ||
| 57 | go logMemStats(ctx, log, memStats) | 58 | go logMemStats(ctx, log, memStats) | ||
| 58 | } | 59 | } | ||
M internal/bar/bar.go +115 -13
| @@ -70,18 +70,22 @@ type Bar struct { | |||
| 70 | 70 | frame gpu.Frame | |
| 71 | 71 | watched [gpu.SlotCount]int // watched eventfds, -1 when none | |
| 72 | 72 | ||
| 73 | - | mods []*module.Module | |
| 74 | - | lay *layout.Layout | |
| 75 | - | modCh chan struct{} | |
| 76 | - | Stats Stats | |
| 77 | - | fatal error | |
| 78 | - | closed bool | |
| 73 | + | runners []runner | |
| 74 | + | mods []*module.Module // the modules of runners, in config order | |
| 75 | + | runCtx context.Context | |
| 76 | + | lay *layout.Layout | |
| 77 | + | modCh chan struct{} | |
| 78 | + | cfgPath string | |
| 79 | + | cfgCh chan config.Config | |
| 80 | + | Stats Stats | |
| 81 | + | fatal error | |
| 82 | + | closed bool | |
| 79 | 83 | } | |
| 80 | 84 | ||
| 81 | 85 | // New builds a bar from a validated config. | |
| 82 | 86 | // display names the Wayland socket; empty uses $WAYLAND_DISPLAY. | |
| 83 | 87 | func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | |
| 84 | - | b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1)} | |
| 88 | + | b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)} | |
| 85 | 89 | var err error | |
| 86 | 90 | if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil { | |
| 87 | 91 | return nil, err | |
| @@ -98,13 +102,103 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | |||
| 98 | 102 | for i := range b.watched { | |
| 99 | 103 | b.watched[i] = -1 | |
| 100 | 104 | } | |
| 101 | - | zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right} | |
| 102 | - | for _, m := range cfg.Module { | |
| 103 | - | b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log)) | |
| 104 | - | } | |
| 105 | 105 | return b, nil | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | // WatchConfig makes Run reload the config file at path when it changes. Call | |
| 109 | + | // it before Run. | |
| 110 | + | func (b *Bar) WatchConfig(path string) { b.cfgPath = path } | |
| 111 | + | ||
| 112 | + | // runner is one started module and how to stop it. | |
| 113 | + | type runner struct { | |
| 114 | + | cfg config.Module | |
| 115 | + | m *module.Module | |
| 116 | + | cancel context.CancelFunc | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | var zones = map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right} | |
| 120 | + | ||
| 121 | + | // syncModules makes the running modules match want. A module with the same | |
| 122 | + | // name, zone and command keeps running; every other one is stopped, and a new | |
| 123 | + | // or changed one is started. | |
| 124 | + | func (b *Bar) syncModules(want []config.Module) { | |
| 125 | + | old := make(map[string]runner, len(b.runners)) | |
| 126 | + | for _, r := range b.runners { | |
| 127 | + | old[r.cfg.Name] = r | |
| 128 | + | } | |
| 129 | + | next := make([]runner, 0, len(want)) | |
| 130 | + | for _, w := range want { | |
| 131 | + | if r, ok := old[w.Name]; ok { | |
| 132 | + | delete(old, w.Name) | |
| 133 | + | if r.cfg == w { | |
| 134 | + | next = append(next, r) | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | r.cancel() | |
| 138 | + | } | |
| 139 | + | ctx, cancel := context.WithCancel(b.runCtx) | |
| 140 | + | m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log) | |
| 141 | + | go m.Run(ctx) | |
| 142 | + | next = append(next, runner{cfg: w, m: m, cancel: cancel}) | |
| 143 | + | } | |
| 144 | + | for _, r := range old { | |
| 145 | + | r.cancel() | |
| 146 | + | } | |
| 147 | + | b.runners = next | |
| 148 | + | b.mods = b.mods[:0] | |
| 149 | + | for _, r := range next { | |
| 150 | + | b.mods = append(b.mods, r.m) | |
| 151 | + | } | |
| 152 | + | if b.lay != nil { | |
| 153 | + | b.lay.SetModules(b.mods) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // applyConfig switches to next. Nothing changes if it cannot be applied. | |
| 158 | + | func (b *Bar) applyConfig(next config.Config) { | |
| 159 | + | old := b.cfg | |
| 160 | + | fg, err := config.ParseColor(next.Bar.Foreground) | |
| 161 | + | if err != nil { | |
| 162 | + | b.log.Warn("config not applied", "err", err) | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | bg, err := config.ParseColor(next.Bar.Background) | |
| 166 | + | if err != nil { | |
| 167 | + | b.log.Warn("config not applied", "err", err) | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | fontChanged := next.Bar.Font != old.Bar.Font | |
| 171 | + | reg, bold := b.regularPath, b.boldPath | |
| 172 | + | if fontChanged { | |
| 173 | + | if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil { | |
| 174 | + | b.log.Warn("config not applied", "err", err) | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil { | |
| 178 | + | bold = reg | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | if next.Bar.Output != old.Bar.Output { | |
| 182 | + | b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output) | |
| 183 | + | next.Bar.Output = old.Bar.Output | |
| 184 | + | } | |
| 185 | + | b.cfg = next | |
| 186 | + | b.regularPath, b.boldPath = reg, bold | |
| 187 | + | b.syncModules(next.Module) | |
| 188 | + | if fg != b.fg || bg != b.bg { | |
| 189 | + | b.fg, b.bg = fg, bg | |
| 190 | + | if b.lay != nil { | |
| 191 | + | b.lay.SetColors(fg, bg) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | b.dirty = true | |
| 195 | + | if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale { | |
| 196 | + | b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed | |
| 197 | + | b.reconcile() | |
| 198 | + | } | |
| 199 | + | b.log.Info("config reloaded") | |
| 200 | + | } | |
| 201 | + | ||
| 108 | 202 | // Run connects to the compositor and serves until ctx ends or the surface is | |
| 109 | 203 | // closed. It returns the first fatal error. | |
| 110 | 204 | func (b *Bar) Run(ctx context.Context) (err error) { | |
| @@ -122,8 +216,14 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 122 | 216 | } | |
| 123 | 217 | runCtx, cancel := context.WithCancel(ctx) | |
| 124 | 218 | defer cancel() | |
| 125 | - | for _, m := range b.mods { | |
| 126 | - | go m.Run(runCtx) | |
| 219 | + | b.runCtx = runCtx | |
| 220 | + | b.syncModules(b.cfg.Module) | |
| 221 | + | if b.cfgPath != "" { | |
| 222 | + | go func() { | |
| 223 | + | if err := config.Watch(runCtx, b.cfgPath, b.cfg, b.cfgCh, b.log); err != nil { | |
| 224 | + | b.log.Warn("config reload is off", "err", err) | |
| 225 | + | } | |
| 226 | + | }() | |
| 127 | 227 | } | |
| 128 | 228 | for !b.closed { | |
| 129 | 229 | select { | |
| @@ -135,6 +235,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 135 | 235 | } | |
| 136 | 236 | case <-b.modCh: | |
| 137 | 237 | b.Stats.ModuleWake++ | |
| 238 | + | case next := <-b.cfgCh: | |
| 239 | + | b.applyConfig(next) | |
| 138 | 240 | } | |
| 139 | 241 | if b.fatal != nil { | |
| 140 | 242 | return b.fatal | |
| @@ -70,18 +70,22 @@ type Bar struct { | |||||
| 70 | frame gpu.Frame | 70 | frame gpu.Frame | ||
| 71 | watched [gpu.SlotCount]int // watched eventfds, -1 when none | 71 | watched [gpu.SlotCount]int // watched eventfds, -1 when none | ||
| 72 | 72 | ||||
| 73 | - | mods []*module.Module | 73 | + | runners []runner |
| 74 | - | lay *layout.Layout | 74 | + | mods []*module.Module // the modules of runners, in config order |
| 75 | - | modCh chan struct{} | 75 | + | runCtx context.Context |
| 76 | - | Stats Stats | 76 | + | lay *layout.Layout |
| 77 | - | fatal error | 77 | + | modCh chan struct{} |
| 78 | - | closed bool | 78 | + | cfgPath string |
| 79 | + | cfgCh chan config.Config | |||
| 80 | + | Stats Stats | |||
| 81 | + | fatal error | |||
| 82 | + | closed bool | |||
| 79 | } | 83 | } | ||
| 80 | 84 | ||||
| 81 | // New builds a bar from a validated config. | 85 | // New builds a bar from a validated config. | ||
| 82 | // display names the Wayland socket; empty uses $WAYLAND_DISPLAY. | 86 | // display names the Wayland socket; empty uses $WAYLAND_DISPLAY. | ||
| 83 | func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | 87 | func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | ||
| 84 | - | b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1)} | 88 | + | b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)} |
| 85 | var err error | 89 | var err error | ||
| 86 | if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil { | 90 | if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil { | ||
| 87 | return nil, err | 91 | return nil, err | ||
| @@ -98,13 +102,103 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) { | |||||
| 98 | for i := range b.watched { | 102 | for i := range b.watched { | ||
| 99 | b.watched[i] = -1 | 103 | b.watched[i] = -1 | ||
| 100 | } | 104 | } | ||
| 101 | - | zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right} | |||
| 102 | - | for _, m := range cfg.Module { | |||
| 103 | - | b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log)) | |||
| 104 | - | } | |||
| 105 | return b, nil | 105 | return b, nil | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // WatchConfig makes Run reload the config file at path when it changes. Call | |||
| 109 | + | // it before Run. | |||
| 110 | + | func (b *Bar) WatchConfig(path string) { b.cfgPath = path } | |||
| 111 | + | ||||
| 112 | + | // runner is one started module and how to stop it. | |||
| 113 | + | type runner struct { | |||
| 114 | + | cfg config.Module | |||
| 115 | + | m *module.Module | |||
| 116 | + | cancel context.CancelFunc | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | var zones = map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right} | |||
| 120 | + | ||||
| 121 | + | // syncModules makes the running modules match want. A module with the same | |||
| 122 | + | // name, zone and command keeps running; every other one is stopped, and a new | |||
| 123 | + | // or changed one is started. | |||
| 124 | + | func (b *Bar) syncModules(want []config.Module) { | |||
| 125 | + | old := make(map[string]runner, len(b.runners)) | |||
| 126 | + | for _, r := range b.runners { | |||
| 127 | + | old[r.cfg.Name] = r | |||
| 128 | + | } | |||
| 129 | + | next := make([]runner, 0, len(want)) | |||
| 130 | + | for _, w := range want { | |||
| 131 | + | if r, ok := old[w.Name]; ok { | |||
| 132 | + | delete(old, w.Name) | |||
| 133 | + | if r.cfg == w { | |||
| 134 | + | next = append(next, r) | |||
| 135 | + | continue | |||
| 136 | + | } | |||
| 137 | + | r.cancel() | |||
| 138 | + | } | |||
| 139 | + | ctx, cancel := context.WithCancel(b.runCtx) | |||
| 140 | + | m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log) | |||
| 141 | + | go m.Run(ctx) | |||
| 142 | + | next = append(next, runner{cfg: w, m: m, cancel: cancel}) | |||
| 143 | + | } | |||
| 144 | + | for _, r := range old { | |||
| 145 | + | r.cancel() | |||
| 146 | + | } | |||
| 147 | + | b.runners = next | |||
| 148 | + | b.mods = b.mods[:0] | |||
| 149 | + | for _, r := range next { | |||
| 150 | + | b.mods = append(b.mods, r.m) | |||
| 151 | + | } | |||
| 152 | + | if b.lay != nil { | |||
| 153 | + | b.lay.SetModules(b.mods) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // applyConfig switches to next. Nothing changes if it cannot be applied. | |||
| 158 | + | func (b *Bar) applyConfig(next config.Config) { | |||
| 159 | + | old := b.cfg | |||
| 160 | + | fg, err := config.ParseColor(next.Bar.Foreground) | |||
| 161 | + | if err != nil { | |||
| 162 | + | b.log.Warn("config not applied", "err", err) | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | bg, err := config.ParseColor(next.Bar.Background) | |||
| 166 | + | if err != nil { | |||
| 167 | + | b.log.Warn("config not applied", "err", err) | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | fontChanged := next.Bar.Font != old.Bar.Font | |||
| 171 | + | reg, bold := b.regularPath, b.boldPath | |||
| 172 | + | if fontChanged { | |||
| 173 | + | if reg, err = glyph.FindFont(next.Bar.Font, false); err != nil { | |||
| 174 | + | b.log.Warn("config not applied", "err", err) | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | if bold, err = glyph.FindFont(next.Bar.Font, true); err != nil { | |||
| 178 | + | bold = reg | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | if next.Bar.Output != old.Bar.Output { | |||
| 182 | + | b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output) | |||
| 183 | + | next.Bar.Output = old.Bar.Output | |||
| 184 | + | } | |||
| 185 | + | b.cfg = next | |||
| 186 | + | b.regularPath, b.boldPath = reg, bold | |||
| 187 | + | b.syncModules(next.Module) | |||
| 188 | + | if fg != b.fg || bg != b.bg { | |||
| 189 | + | b.fg, b.bg = fg, bg | |||
| 190 | + | if b.lay != nil { | |||
| 191 | + | b.lay.SetColors(fg, bg) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | b.dirty = true | |||
| 195 | + | if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale { | |||
| 196 | + | b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed | |||
| 197 | + | b.reconcile() | |||
| 198 | + | } | |||
| 199 | + | b.log.Info("config reloaded") | |||
| 200 | + | } | |||
| 201 | + | ||||
| 108 | // Run connects to the compositor and serves until ctx ends or the surface is | 202 | // Run connects to the compositor and serves until ctx ends or the surface is | ||
| 109 | // closed. It returns the first fatal error. | 203 | // closed. It returns the first fatal error. | ||
| 110 | func (b *Bar) Run(ctx context.Context) (err error) { | 204 | func (b *Bar) Run(ctx context.Context) (err error) { | ||
| @@ -122,8 +216,14 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||||
| 122 | } | 216 | } | ||
| 123 | runCtx, cancel := context.WithCancel(ctx) | 217 | runCtx, cancel := context.WithCancel(ctx) | ||
| 124 | defer cancel() | 218 | defer cancel() | ||
| 125 | - | for _, m := range b.mods { | 219 | + | b.runCtx = runCtx |
| 126 | - | go m.Run(runCtx) | 220 | + | b.syncModules(b.cfg.Module) |
| 221 | + | if b.cfgPath != "" { | |||
| 222 | + | go func() { | |||
| 223 | + | if err := config.Watch(runCtx, b.cfgPath, b.cfg, b.cfgCh, b.log); err != nil { | |||
| 224 | + | b.log.Warn("config reload is off", "err", err) | |||
| 225 | + | } | |||
| 226 | + | }() | |||
| 127 | } | 227 | } | ||
| 128 | for !b.closed { | 228 | for !b.closed { | ||
| 129 | select { | 229 | select { | ||
| @@ -135,6 +235,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||||
| 135 | } | 235 | } | ||
| 136 | case <-b.modCh: | 236 | case <-b.modCh: | ||
| 137 | b.Stats.ModuleWake++ | 237 | b.Stats.ModuleWake++ | ||
| 238 | + | case next := <-b.cfgCh: | |||
| 239 | + | b.applyConfig(next) | |||
| 138 | } | 240 | } | ||
| 139 | if b.fatal != nil { | 241 | if b.fatal != nil { | ||
| 140 | return b.fatal | 242 | return b.fatal | ||
M internal/config/config.go +5 -0
| @@ -87,6 +87,7 @@ func (c *Config) Validate() error { | |||
| 87 | 87 | return err | |
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | + | seen := map[string]bool{} | |
| 90 | 91 | for i := range c.Module { | |
| 91 | 92 | m := &c.Module[i] | |
| 92 | 93 | if m.Exec == "" { | |
| @@ -95,6 +96,10 @@ func (c *Config) Validate() error { | |||
| 95 | 96 | if m.Name == "" { | |
| 96 | 97 | m.Name = fmt.Sprintf("module%d", i+1) | |
| 97 | 98 | } | |
| 99 | + | if seen[m.Name] { | |
| 100 | + | return fmt.Errorf("config: two modules are named %q", m.Name) | |
| 101 | + | } | |
| 102 | + | seen[m.Name] = true | |
| 98 | 103 | switch m.Zone { | |
| 99 | 104 | case "left", "center", "right": | |
| 100 | 105 | default: | |
| @@ -87,6 +87,7 @@ func (c *Config) Validate() error { | |||||
| 87 | return err | 87 | return err | ||
| 88 | } | 88 | } | ||
| 89 | } | 89 | } | ||
| 90 | + | seen := map[string]bool{} | |||
| 90 | for i := range c.Module { | 91 | for i := range c.Module { | ||
| 91 | m := &c.Module[i] | 92 | m := &c.Module[i] | ||
| 92 | if m.Exec == "" { | 93 | if m.Exec == "" { | ||
| @@ -95,6 +96,10 @@ func (c *Config) Validate() error { | |||||
| 95 | if m.Name == "" { | 96 | if m.Name == "" { | ||
| 96 | m.Name = fmt.Sprintf("module%d", i+1) | 97 | m.Name = fmt.Sprintf("module%d", i+1) | ||
| 97 | } | 98 | } | ||
| 99 | + | if seen[m.Name] { | |||
| 100 | + | return fmt.Errorf("config: two modules are named %q", m.Name) | |||
| 101 | + | } | |||
| 102 | + | seen[m.Name] = true | |||
| 98 | switch m.Zone { | 103 | switch m.Zone { | ||
| 99 | case "left", "center", "right": | 104 | case "left", "center", "right": | ||
| 100 | default: | 105 | default: | ||
A internal/config/watch.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "log/slog" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "reflect" | |
| 10 | + | "slices" | |
| 11 | + | "time" | |
| 12 | + | "unsafe" | |
| 13 | + | ||
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 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 | + | } | |
| 48 | + | ||
| 49 | + | // 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 | |
| 51 | + | // 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. | |
| 57 | + | 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 | + | } | |
| 132 | + | 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 | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | return nil | |
| 175 | + | } | |
| @@ -0,0 +1,175 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "reflect" | |||
| 10 | + | "slices" | |||
| 11 | + | "time" | |||
| 12 | + | "unsafe" | |||
| 13 | + | ||||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 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 | + | } | |||
| 48 | + | ||||
| 49 | + | // 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 | |||
| 51 | + | // 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. | |||
| 57 | + | 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 | + | } | |||
| 132 | + | 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 | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | return nil | |||
| 175 | + | } | |||
A internal/config/watch_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log/slog" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func startWatch(t *testing.T, path string, last Config) <-chan Config { | |
| 13 | + | t.Helper() | |
| 14 | + | out := make(chan Config, 4) | |
| 15 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 16 | + | done := make(chan struct{}) | |
| 17 | + | go func() { | |
| 18 | + | defer close(done) | |
| 19 | + | if err := Watch(ctx, path, last, out, slog.New(slog.DiscardHandler)); err != nil { | |
| 20 | + | t.Error(err) | |
| 21 | + | } | |
| 22 | + | }() | |
| 23 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 24 | + | time.Sleep(150 * time.Millisecond) // let the watch attach | |
| 25 | + | return out | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func recv(t *testing.T, ch <-chan Config) Config { | |
| 29 | + | t.Helper() | |
| 30 | + | select { | |
| 31 | + | case c := <-ch: | |
| 32 | + | return c | |
| 33 | + | case <-time.After(3 * time.Second): | |
| 34 | + | t.Fatal("no config reload") | |
| 35 | + | return Config{} | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func none(t *testing.T, ch <-chan Config) { | |
| 40 | + | t.Helper() | |
| 41 | + | select { | |
| 42 | + | case c := <-ch: | |
| 43 | + | t.Fatalf("unexpected reload: %+v", c.Bar) | |
| 44 | + | case <-time.After(600 * time.Millisecond): | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestWatchInPlaceAndAtomicSaves(t *testing.T) { | |
| 49 | + | path := write(t, "[bar]\nsize = 14\n") | |
| 50 | + | first, err := Load(path, true) | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | ch := startWatch(t, path, first) | |
| 55 | + | ||
| 56 | + | if err := os.WriteFile(path, []byte("[bar]\nsize = 18\n"), 0o600); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if got := recv(t, ch).Bar.Size; got != 18 { | |
| 60 | + | t.Fatalf("in-place save: size = %v", got) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | tmp := path + ".tmp" // what most editors do: write a sibling, rename over | |
| 64 | + | if err := os.WriteFile(tmp, []byte("[bar]\nsize = 20\n"), 0o600); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | if err := os.Rename(tmp, path); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if got := recv(t, ch).Bar.Size; got != 20 { | |
| 71 | + | t.Fatalf("atomic save: size = %v", got) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestWatchKeepsOldConfigOnErrors(t *testing.T) { | |
| 76 | + | path := write(t, "[bar]\nsize = 14\n") | |
| 77 | + | first, _ := Load(path, true) | |
| 78 | + | ch := startWatch(t, path, first) | |
| 79 | + | ||
| 80 | + | os.WriteFile(path, []byte("[bar\n"), 0o600) // syntax error | |
| 81 | + | none(t, ch) | |
| 82 | + | os.WriteFile(path, []byte("[bar]\nsize = 1\n"), 0o600) // out of range | |
| 83 | + | none(t, ch) | |
| 84 | + | os.Remove(path) | |
| 85 | + | none(t, ch) | |
| 86 | + | ||
| 87 | + | os.WriteFile(path, []byte("[bar]\nsize = 16\n"), 0o600) // recovers | |
| 88 | + | if got := recv(t, ch).Bar.Size; got != 16 { | |
| 89 | + | t.Fatalf("after recovery: size = %v", got) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestWatchIgnoresUnchangedSave(t *testing.T) { | |
| 94 | + | path := write(t, "[bar]\nsize = 14\n") | |
| 95 | + | first, _ := Load(path, true) | |
| 96 | + | ch := startWatch(t, path, first) | |
| 97 | + | os.WriteFile(path, []byte("# a comment\n[bar]\nsize = 14\n"), 0o600) | |
| 98 | + | none(t, ch) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestWatchFollowsSymlink(t *testing.T) { | |
| 102 | + | dir := t.TempDir() | |
| 103 | + | real := filepath.Join(dir, "dotfiles", "config.toml") | |
| 104 | + | os.MkdirAll(filepath.Dir(real), 0o700) | |
| 105 | + | os.WriteFile(real, []byte("[bar]\nsize = 14\n"), 0o600) | |
| 106 | + | link := filepath.Join(dir, "config.toml") | |
| 107 | + | if err := os.Symlink(real, link); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | first, _ := Load(link, true) | |
| 111 | + | ch := startWatch(t, link, first) | |
| 112 | + | os.WriteFile(real, []byte("[bar]\nsize = 22\n"), 0o600) | |
| 113 | + | if got := recv(t, ch).Bar.Size; got != 22 { | |
| 114 | + | t.Fatalf("symlink target edit: size = %v", got) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestDuplicateModuleNames(t *testing.T) { | |
| 119 | + | _, err := Load(write(t, "[[module]]\nname=\"a\"\nzone=\"left\"\nexec=\"x\"\n[[module]]\nname=\"a\"\nzone=\"right\"\nexec=\"y\"\n"), true) | |
| 120 | + | if err == nil { | |
| 121 | + | t.Fatal("duplicate module names must fail") | |
| 122 | + | } | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "log/slog" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func startWatch(t *testing.T, path string, last Config) <-chan Config { | |||
| 13 | + | t.Helper() | |||
| 14 | + | out := make(chan Config, 4) | |||
| 15 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 16 | + | done := make(chan struct{}) | |||
| 17 | + | go func() { | |||
| 18 | + | defer close(done) | |||
| 19 | + | if err := Watch(ctx, path, last, out, slog.New(slog.DiscardHandler)); err != nil { | |||
| 20 | + | t.Error(err) | |||
| 21 | + | } | |||
| 22 | + | }() | |||
| 23 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 24 | + | time.Sleep(150 * time.Millisecond) // let the watch attach | |||
| 25 | + | return out | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func recv(t *testing.T, ch <-chan Config) Config { | |||
| 29 | + | t.Helper() | |||
| 30 | + | select { | |||
| 31 | + | case c := <-ch: | |||
| 32 | + | return c | |||
| 33 | + | case <-time.After(3 * time.Second): | |||
| 34 | + | t.Fatal("no config reload") | |||
| 35 | + | return Config{} | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func none(t *testing.T, ch <-chan Config) { | |||
| 40 | + | t.Helper() | |||
| 41 | + | select { | |||
| 42 | + | case c := <-ch: | |||
| 43 | + | t.Fatalf("unexpected reload: %+v", c.Bar) | |||
| 44 | + | case <-time.After(600 * time.Millisecond): | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestWatchInPlaceAndAtomicSaves(t *testing.T) { | |||
| 49 | + | path := write(t, "[bar]\nsize = 14\n") | |||
| 50 | + | first, err := Load(path, true) | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | ch := startWatch(t, path, first) | |||
| 55 | + | ||||
| 56 | + | if err := os.WriteFile(path, []byte("[bar]\nsize = 18\n"), 0o600); err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if got := recv(t, ch).Bar.Size; got != 18 { | |||
| 60 | + | t.Fatalf("in-place save: size = %v", got) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | tmp := path + ".tmp" // what most editors do: write a sibling, rename over | |||
| 64 | + | if err := os.WriteFile(tmp, []byte("[bar]\nsize = 20\n"), 0o600); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | if err := os.Rename(tmp, path); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | if got := recv(t, ch).Bar.Size; got != 20 { | |||
| 71 | + | t.Fatalf("atomic save: size = %v", got) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestWatchKeepsOldConfigOnErrors(t *testing.T) { | |||
| 76 | + | path := write(t, "[bar]\nsize = 14\n") | |||
| 77 | + | first, _ := Load(path, true) | |||
| 78 | + | ch := startWatch(t, path, first) | |||
| 79 | + | ||||
| 80 | + | os.WriteFile(path, []byte("[bar\n"), 0o600) // syntax error | |||
| 81 | + | none(t, ch) | |||
| 82 | + | os.WriteFile(path, []byte("[bar]\nsize = 1\n"), 0o600) // out of range | |||
| 83 | + | none(t, ch) | |||
| 84 | + | os.Remove(path) | |||
| 85 | + | none(t, ch) | |||
| 86 | + | ||||
| 87 | + | os.WriteFile(path, []byte("[bar]\nsize = 16\n"), 0o600) // recovers | |||
| 88 | + | if got := recv(t, ch).Bar.Size; got != 16 { | |||
| 89 | + | t.Fatalf("after recovery: size = %v", got) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestWatchIgnoresUnchangedSave(t *testing.T) { | |||
| 94 | + | path := write(t, "[bar]\nsize = 14\n") | |||
| 95 | + | first, _ := Load(path, true) | |||
| 96 | + | ch := startWatch(t, path, first) | |||
| 97 | + | os.WriteFile(path, []byte("# a comment\n[bar]\nsize = 14\n"), 0o600) | |||
| 98 | + | none(t, ch) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestWatchFollowsSymlink(t *testing.T) { | |||
| 102 | + | dir := t.TempDir() | |||
| 103 | + | real := filepath.Join(dir, "dotfiles", "config.toml") | |||
| 104 | + | os.MkdirAll(filepath.Dir(real), 0o700) | |||
| 105 | + | os.WriteFile(real, []byte("[bar]\nsize = 14\n"), 0o600) | |||
| 106 | + | link := filepath.Join(dir, "config.toml") | |||
| 107 | + | if err := os.Symlink(real, link); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | first, _ := Load(link, true) | |||
| 111 | + | ch := startWatch(t, link, first) | |||
| 112 | + | os.WriteFile(real, []byte("[bar]\nsize = 22\n"), 0o600) | |||
| 113 | + | if got := recv(t, ch).Bar.Size; got != 22 { | |||
| 114 | + | t.Fatalf("symlink target edit: size = %v", got) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestDuplicateModuleNames(t *testing.T) { | |||
| 119 | + | _, err := Load(write(t, "[[module]]\nname=\"a\"\nzone=\"left\"\nexec=\"x\"\n[[module]]\nname=\"a\"\nzone=\"right\"\nexec=\"y\"\n"), true) | |||
| 120 | + | if err == nil { | |||
| 121 | + | t.Fatal("duplicate module names must fail") | |||
| 122 | + | } | |||
| 123 | + | } | |||
M internal/layout/layout.go +41 -9
| @@ -30,18 +30,50 @@ type Layout struct { | |||
| 30 | 30 | // New builds a layout for a row of cols cells. | |
| 31 | 31 | func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout { | |
| 32 | 32 | l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)} | |
| 33 | + | l.SetModules(mods) | |
| 34 | + | return l | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func newSource(m *module.Module, cols int) *Source { | |
| 38 | + | s := &Source{ | |
| 39 | + | M: m, | |
| 40 | + | screen: vt.NewScreen(cols, 1), | |
| 41 | + | frame: make([]byte, 0, module.MaxFrame), | |
| 42 | + | input: make([]byte, 0, module.MaxFrame+16), | |
| 43 | + | cells: make([]gpu.Cell, 0, cols), | |
| 44 | + | } | |
| 45 | + | s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips | |
| 46 | + | return s | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // SetModules replaces the module list. A module that is already shown keeps | |
| 50 | + | // its parsed text; new ones start empty. The order of mods is the order | |
| 51 | + | // within each zone. | |
| 52 | + | func (l *Layout) SetModules(mods []*module.Module) { | |
| 53 | + | old := make(map[*module.Module]*Source, len(l.sources)) | |
| 54 | + | for _, s := range l.sources { | |
| 55 | + | old[s.M] = s | |
| 56 | + | } | |
| 57 | + | l.sources = make([]*Source, 0, len(mods)) | |
| 33 | 58 | for _, m := range mods { | |
| 34 | - | s := &Source{ | |
| 35 | - | M: m, | |
| 36 | - | screen: vt.NewScreen(cols, 1), | |
| 37 | - | frame: make([]byte, 0, module.MaxFrame), | |
| 38 | - | input: make([]byte, 0, module.MaxFrame+16), | |
| 39 | - | cells: make([]gpu.Cell, 0, cols), | |
| 59 | + | if s, ok := old[m]; ok { | |
| 60 | + | l.sources = append(l.sources, s) | |
| 61 | + | } else { | |
| 62 | + | l.sources = append(l.sources, newSource(m, l.cols)) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // SetColors changes the default colors and reparses every module's last frame. | |
| 68 | + | func (l *Layout) SetColors(fg, bg [3]uint8) { | |
| 69 | + | l.fg, l.bg = fg, bg | |
| 70 | + | for _, s := range l.sources { | |
| 71 | + | if s.failed { | |
| 72 | + | s.cells = l.marker(s.cells[:0], s.M.Name) | |
| 73 | + | } else if len(s.frame) > 0 { | |
| 74 | + | s.parse(fg, bg) | |
| 40 | 75 | } | |
| 41 | - | s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips | |
| 42 | - | l.sources = append(l.sources, s) | |
| 43 | 76 | } | |
| 44 | - | return l | |
| 45 | 77 | } | |
| 46 | 78 | ||
| 47 | 79 | // Resize changes the row width and reparses the last frame of every module. | |
| @@ -30,18 +30,50 @@ type Layout struct { | |||||
| 30 | // New builds a layout for a row of cols cells. | 30 | // New builds a layout for a row of cols cells. | ||
| 31 | func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout { | 31 | func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout { | ||
| 32 | l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)} | 32 | l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)} | ||
| 33 | + | l.SetModules(mods) | |||
| 34 | + | return l | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func newSource(m *module.Module, cols int) *Source { | |||
| 38 | + | s := &Source{ | |||
| 39 | + | M: m, | |||
| 40 | + | screen: vt.NewScreen(cols, 1), | |||
| 41 | + | frame: make([]byte, 0, module.MaxFrame), | |||
| 42 | + | input: make([]byte, 0, module.MaxFrame+16), | |||
| 43 | + | cells: make([]gpu.Cell, 0, cols), | |||
| 44 | + | } | |||
| 45 | + | s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips | |||
| 46 | + | return s | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // SetModules replaces the module list. A module that is already shown keeps | |||
| 50 | + | // its parsed text; new ones start empty. The order of mods is the order | |||
| 51 | + | // within each zone. | |||
| 52 | + | func (l *Layout) SetModules(mods []*module.Module) { | |||
| 53 | + | old := make(map[*module.Module]*Source, len(l.sources)) | |||
| 54 | + | for _, s := range l.sources { | |||
| 55 | + | old[s.M] = s | |||
| 56 | + | } | |||
| 57 | + | l.sources = make([]*Source, 0, len(mods)) | |||
| 33 | for _, m := range mods { | 58 | for _, m := range mods { | ||
| 34 | - | s := &Source{ | 59 | + | if s, ok := old[m]; ok { |
| 35 | - | M: m, | 60 | + | l.sources = append(l.sources, s) |
| 36 | - | screen: vt.NewScreen(cols, 1), | 61 | + | } else { |
| 37 | - | frame: make([]byte, 0, module.MaxFrame), | 62 | + | l.sources = append(l.sources, newSource(m, l.cols)) |
| 38 | - | input: make([]byte, 0, module.MaxFrame+16), | 63 | + | } |
| 39 | - | cells: make([]gpu.Cell, 0, cols), | 64 | + | } |
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // SetColors changes the default colors and reparses every module's last frame. | |||
| 68 | + | func (l *Layout) SetColors(fg, bg [3]uint8) { | |||
| 69 | + | l.fg, l.bg = fg, bg | |||
| 70 | + | for _, s := range l.sources { | |||
| 71 | + | if s.failed { | |||
| 72 | + | s.cells = l.marker(s.cells[:0], s.M.Name) | |||
| 73 | + | } else if len(s.frame) > 0 { | |||
| 74 | + | s.parse(fg, bg) | |||
| 40 | } | 75 | } | ||
| 41 | - | s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips | |||
| 42 | - | l.sources = append(l.sources, s) | |||
| 43 | } | 76 | } | ||
| 44 | - | return l | |||
| 45 | } | 77 | } | ||
| 46 | 78 | ||||
| 47 | // Resize changes the row width and reparses the last frame of every module. | 79 | // Resize changes the row width and reparses the last frame of every module. | ||