fix(daemon): refresh bar scripts immediately on config change
ApplyConfig cleared cached anchor output and repainted without triggering a run, so the segment stayed blank until the next poller tick. With a long bar.interval that reads as the config being ignored. Force a refresh for every live session when the bar config actually changed.
- commit
- a593915d05d8a850137c6b128364c7b82b743f21
- author
- brice · · 2026-07-20 08:18 UTC
- parent
- c9a1da5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +47 -0
M internal/usecase/daemon/bar_refresh.go +13 -0
| @@ -150,6 +150,19 @@ func (d *Daemon) barScriptPoller(ctx context.Context) { | |||
| 150 | 150 | } | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | + | // refreshBarScriptsAllSessions forces a bar-script run for every live session. | |
| 154 | + | // Called after a config change so a new command takes effect immediately rather | |
| 155 | + | // than leaving the anchor blank until the poller's next tick. | |
| 156 | + | func (d *Daemon) refreshBarScriptsAllSessions() { | |
| 157 | + | if d == nil || d.barScripts == nil || d.clock == nil { | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | now := d.clock.Now() | |
| 161 | + | for _, sess := range d.sessionsSnapshot() { | |
| 162 | + | d.refreshBarScriptsIfDue(sess, now, true) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 153 | 166 | func (d *Daemon) sessionsSnapshot() []*session { | |
| 154 | 167 | d.mu.Lock() | |
| 155 | 168 | defer d.mu.Unlock() | |
| @@ -150,6 +150,19 @@ func (d *Daemon) barScriptPoller(ctx context.Context) { | |||||
| 150 | } | 150 | } | ||
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | + | // refreshBarScriptsAllSessions forces a bar-script run for every live session. | |||
| 154 | + | // Called after a config change so a new command takes effect immediately rather | |||
| 155 | + | // than leaving the anchor blank until the poller's next tick. | |||
| 156 | + | func (d *Daemon) refreshBarScriptsAllSessions() { | |||
| 157 | + | if d == nil || d.barScripts == nil || d.clock == nil { | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | now := d.clock.Now() | |||
| 161 | + | for _, sess := range d.sessionsSnapshot() { | |||
| 162 | + | d.refreshBarScriptsIfDue(sess, now, true) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 153 | func (d *Daemon) sessionsSnapshot() []*session { | 166 | func (d *Daemon) sessionsSnapshot() []*session { | ||
| 154 | d.mu.Lock() | 167 | d.mu.Lock() | ||
| 155 | defer d.mu.Unlock() | 168 | defer d.mu.Unlock() | ||
M internal/usecase/daemon/bar_refresh_test.go +28 -0
| @@ -213,6 +213,34 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||
| 213 | 213 | require.Equal(t, "bottom-good", state.bottomRight) | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | + | func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |
| 217 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |
| 218 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 219 | + | sess := newBarRefreshTestSession() | |
| 220 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 221 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 222 | + | ||
| 223 | + | // Nothing has run yet, and the 60s interval means a non-forced tick would | |
| 224 | + | // not be due for a long time. | |
| 225 | + | d.refreshBarScriptsAllSessions() | |
| 226 | + | waitBarRefreshIdle(t, d) | |
| 227 | + | ||
| 228 | + | require.Len(t, r.calls, 2, "should force a run for every live session") | |
| 229 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) { | |
| 233 | + | r := &fakeBarRunner{} | |
| 234 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 235 | + | ||
| 236 | + | before := d.barScripts.version | |
| 237 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |
| 238 | + | require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate") | |
| 239 | + | ||
| 240 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}}) | |
| 241 | + | require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate") | |
| 242 | + | } | |
| 243 | + | ||
| 216 | 244 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |
| 217 | 245 | var logs bytes.Buffer | |
| 218 | 246 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |
| @@ -213,6 +213,34 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||||
| 213 | require.Equal(t, "bottom-good", state.bottomRight) | 213 | require.Equal(t, "bottom-good", state.bottomRight) | ||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | + | func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 217 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1"}} | |||
| 218 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |||
| 219 | + | sess := newBarRefreshTestSession() | |||
| 220 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 221 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |||
| 222 | + | ||||
| 223 | + | // Nothing has run yet, and the 60s interval means a non-forced tick would | |||
| 224 | + | // not be due for a long time. | |||
| 225 | + | d.refreshBarScriptsAllSessions() | |||
| 226 | + | waitBarRefreshIdle(t, d) | |||
| 227 | + | ||||
| 228 | + | require.Len(t, r.calls, 2, "should force a run for every live session") | |||
| 229 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) { | |||
| 233 | + | r := &fakeBarRunner{} | |||
| 234 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 235 | + | ||||
| 236 | + | before := d.barScripts.version | |||
| 237 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |||
| 238 | + | require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate") | |||
| 239 | + | ||||
| 240 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}}) | |||
| 241 | + | require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 216 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | 244 | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | ||
| 217 | var logs bytes.Buffer | 245 | var logs bytes.Buffer | ||
| 218 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | 246 | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | ||
M internal/usecase/daemon/config.go +6 -0
| @@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 57 | 57 | tabs := cfg.Tabs | |
| 58 | 58 | d.tabsConfig.Store(&tabs) | |
| 59 | 59 | d.storeThemeConfig(cfg) | |
| 60 | + | barChanged := false | |
| 60 | 61 | if d.barScripts != nil { | |
| 61 | 62 | d.barScripts.mu.Lock() | |
| 62 | 63 | d.barScripts.initLocked() | |
| @@ -67,10 +68,15 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 67 | 68 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 68 | 69 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 69 | 70 | d.barScripts.pending = make(map[domain.SessionID]bool) | |
| 71 | + | d.barScripts.lastFailure = make(map[barScriptFailureKey]string) | |
| 70 | 72 | d.barScripts.version++ | |
| 73 | + | barChanged = true | |
| 71 | 74 | } | |
| 72 | 75 | d.barScripts.mu.Unlock() | |
| 73 | 76 | } | |
| 77 | + | if barChanged { | |
| 78 | + | d.refreshBarScriptsAllSessions() | |
| 79 | + | } | |
| 74 | 80 | d.reapplyThemeAllSessions() | |
| 75 | 81 | d.repaintAllAttachedClients() | |
| 76 | 82 | } | |
| @@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 57 | tabs := cfg.Tabs | 57 | tabs := cfg.Tabs | ||
| 58 | d.tabsConfig.Store(&tabs) | 58 | d.tabsConfig.Store(&tabs) | ||
| 59 | d.storeThemeConfig(cfg) | 59 | d.storeThemeConfig(cfg) | ||
| 60 | + | barChanged := false | |||
| 60 | if d.barScripts != nil { | 61 | if d.barScripts != nil { | ||
| 61 | d.barScripts.mu.Lock() | 62 | d.barScripts.mu.Lock() | ||
| 62 | d.barScripts.initLocked() | 63 | d.barScripts.initLocked() | ||
| @@ -67,10 +68,15 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 67 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | 68 | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | ||
| 68 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | 69 | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | ||
| 69 | d.barScripts.pending = make(map[domain.SessionID]bool) | 70 | d.barScripts.pending = make(map[domain.SessionID]bool) | ||
| 71 | + | d.barScripts.lastFailure = make(map[barScriptFailureKey]string) | |||
| 70 | d.barScripts.version++ | 72 | d.barScripts.version++ | ||
| 73 | + | barChanged = true | |||
| 71 | } | 74 | } | ||
| 72 | d.barScripts.mu.Unlock() | 75 | d.barScripts.mu.Unlock() | ||
| 73 | } | 76 | } | ||
| 77 | + | if barChanged { | |||
| 78 | + | d.refreshBarScriptsAllSessions() | |||
| 79 | + | } | |||
| 74 | d.reapplyThemeAllSessions() | 80 | d.reapplyThemeAllSessions() | ||
| 75 | d.repaintAllAttachedClients() | 81 | d.repaintAllAttachedClients() | ||
| 76 | } | 82 | } | ||