fix(daemon): re-arm bar poller when the interval changes
barScriptPoller reset its timer only after the current one fired, so lowering bar.interval took up to the old interval to take effect. Signal the poller on config change and arm a fresh timer at the new interval.
- commit
- 747b112a5dbc54a5e95fc6b5f1ea5f693c72a321
- author
- brice · · 2026-07-20 08:22 UTC
- parent
- a593915
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +43 -2
M internal/usecase/daemon/bar_refresh.go +22 -2
| @@ -40,6 +40,10 @@ type barScriptState struct { | |||
| 40 | 40 | pending map[domain.SessionID]bool | |
| 41 | 41 | lastFailure map[barScriptFailureKey]string | |
| 42 | 42 | version uint64 | |
| 43 | + | ||
| 44 | + | // reload wakes barScriptPoller so an interval change takes effect without | |
| 45 | + | // waiting out the timer armed under the previous interval. | |
| 46 | + | reload chan struct{} | |
| 43 | 47 | } | |
| 44 | 48 | ||
| 45 | 49 | func effectiveBarInterval(d time.Duration) time.Duration { | |
| @@ -134,18 +138,34 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 134 | 138 | return ctx, env, true | |
| 135 | 139 | } | |
| 136 | 140 | ||
| 141 | + | // signalBarPollerReload wakes the poller without blocking. The channel is | |
| 142 | + | // buffered to one, so a pending signal already conveys "config changed". | |
| 143 | + | func (d *Daemon) signalBarPollerReload() { | |
| 144 | + | if d == nil || d.barScripts == nil || d.barScripts.reload == nil { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | select { | |
| 148 | + | case d.barScripts.reload <- struct{}{}: | |
| 149 | + | default: | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 137 | 153 | func (d *Daemon) barScriptPoller(ctx context.Context) { | |
| 138 | 154 | timer := d.clock.NewTimer(d.barScriptInterval()) | |
| 139 | - | defer timer.Stop() | |
| 155 | + | defer func() { timer.Stop() }() | |
| 140 | 156 | for { | |
| 141 | 157 | select { | |
| 142 | 158 | case <-ctx.Done(): | |
| 143 | 159 | return | |
| 160 | + | case <-d.barScripts.reload: | |
| 161 | + | timer.Stop() | |
| 162 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |
| 144 | 163 | case now := <-timer.C(): | |
| 145 | 164 | for _, sess := range d.sessionsSnapshot() { | |
| 146 | 165 | d.refreshBarScriptsIfDue(sess, now, false) | |
| 147 | 166 | } | |
| 148 | - | timer.Reset(d.barScriptInterval()) | |
| 167 | + | timer.Stop() | |
| 168 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |
| 149 | 169 | } | |
| 150 | 170 | } | |
| 151 | 171 | } | |
| @@ -40,6 +40,10 @@ type barScriptState struct { | |||||
| 40 | pending map[domain.SessionID]bool | 40 | pending map[domain.SessionID]bool | ||
| 41 | lastFailure map[barScriptFailureKey]string | 41 | lastFailure map[barScriptFailureKey]string | ||
| 42 | version uint64 | 42 | version uint64 | ||
| 43 | + | ||||
| 44 | + | // reload wakes barScriptPoller so an interval change takes effect without | |||
| 45 | + | // waiting out the timer armed under the previous interval. | |||
| 46 | + | reload chan struct{} | |||
| 43 | } | 47 | } | ||
| 44 | 48 | ||||
| 45 | func effectiveBarInterval(d time.Duration) time.Duration { | 49 | func effectiveBarInterval(d time.Duration) time.Duration { | ||
| @@ -134,18 +138,34 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||||
| 134 | return ctx, env, true | 138 | return ctx, env, true | ||
| 135 | } | 139 | } | ||
| 136 | 140 | ||||
| 141 | + | // signalBarPollerReload wakes the poller without blocking. The channel is | |||
| 142 | + | // buffered to one, so a pending signal already conveys "config changed". | |||
| 143 | + | func (d *Daemon) signalBarPollerReload() { | |||
| 144 | + | if d == nil || d.barScripts == nil || d.barScripts.reload == nil { | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | select { | |||
| 148 | + | case d.barScripts.reload <- struct{}{}: | |||
| 149 | + | default: | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 137 | func (d *Daemon) barScriptPoller(ctx context.Context) { | 153 | func (d *Daemon) barScriptPoller(ctx context.Context) { | ||
| 138 | timer := d.clock.NewTimer(d.barScriptInterval()) | 154 | timer := d.clock.NewTimer(d.barScriptInterval()) | ||
| 139 | - | defer timer.Stop() | 155 | + | defer func() { timer.Stop() }() |
| 140 | for { | 156 | for { | ||
| 141 | select { | 157 | select { | ||
| 142 | case <-ctx.Done(): | 158 | case <-ctx.Done(): | ||
| 143 | return | 159 | return | ||
| 160 | + | case <-d.barScripts.reload: | |||
| 161 | + | timer.Stop() | |||
| 162 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |||
| 144 | case now := <-timer.C(): | 163 | case now := <-timer.C(): | ||
| 145 | for _, sess := range d.sessionsSnapshot() { | 164 | for _, sess := range d.sessionsSnapshot() { | ||
| 146 | d.refreshBarScriptsIfDue(sess, now, false) | 165 | d.refreshBarScriptsIfDue(sess, now, false) | ||
| 147 | } | 166 | } | ||
| 148 | - | timer.Reset(d.barScriptInterval()) | 167 | + | timer.Stop() |
| 168 | + | timer = d.clock.NewTimer(d.barScriptInterval()) | |||
| 149 | } | 169 | } | ||
| 150 | } | 170 | } | ||
| 151 | } | 171 | } | ||
M internal/usecase/daemon/bar_refresh_test.go +19 -0
| @@ -434,6 +434,25 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 434 | 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | |
| 435 | 435 | } | |
| 436 | 436 | ||
| 437 | + | func TestApplyConfigSignalsPollerReload(t *testing.T) { | |
| 438 | + | r := &fakeBarRunner{} | |
| 439 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 440 | + | d.barScripts.reload = make(chan struct{}, 1) | |
| 441 | + | ||
| 442 | + | cfg := domain.Defaults() | |
| 443 | + | cfg.Bar.TopRight = "t" | |
| 444 | + | cfg.Bar.BottomRight = "b" | |
| 445 | + | cfg.Bar.Interval = time.Second | |
| 446 | + | d.ApplyConfig(cfg) | |
| 447 | + | ||
| 448 | + | select { | |
| 449 | + | case <-d.barScripts.reload: | |
| 450 | + | default: | |
| 451 | + | t.Fatal("ApplyConfig should signal the poller to re-arm its timer") | |
| 452 | + | } | |
| 453 | + | require.Equal(t, time.Second, d.barScriptInterval()) | |
| 454 | + | } | |
| 455 | + | ||
| 437 | 456 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | |
| 438 | 457 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |
| 439 | 458 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| @@ -434,6 +434,25 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||||
| 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | 434 | require.Equal(t, "bottom-b", stateB.bottomRight) | ||
| 435 | } | 435 | } | ||
| 436 | 436 | ||||
| 437 | + | func TestApplyConfigSignalsPollerReload(t *testing.T) { | |||
| 438 | + | r := &fakeBarRunner{} | |||
| 439 | + | d := newBarRefreshTestDaemon(r, 60*time.Second) | |||
| 440 | + | d.barScripts.reload = make(chan struct{}, 1) | |||
| 441 | + | ||||
| 442 | + | cfg := domain.Defaults() | |||
| 443 | + | cfg.Bar.TopRight = "t" | |||
| 444 | + | cfg.Bar.BottomRight = "b" | |||
| 445 | + | cfg.Bar.Interval = time.Second | |||
| 446 | + | d.ApplyConfig(cfg) | |||
| 447 | + | ||||
| 448 | + | select { | |||
| 449 | + | case <-d.barScripts.reload: | |||
| 450 | + | default: | |||
| 451 | + | t.Fatal("ApplyConfig should signal the poller to re-arm its timer") | |||
| 452 | + | } | |||
| 453 | + | require.Equal(t, time.Second, d.barScriptInterval()) | |||
| 454 | + | } | |||
| 455 | + | ||||
| 437 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | 456 | func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon { | ||
| 438 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | 457 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | ||
| 439 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | 458 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | ||
M internal/usecase/daemon/config.go +1 -0
| @@ -75,6 +75,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 75 | 75 | d.barScripts.mu.Unlock() | |
| 76 | 76 | } | |
| 77 | 77 | if barChanged { | |
| 78 | + | d.signalBarPollerReload() | |
| 78 | 79 | d.refreshBarScriptsAllSessions() | |
| 79 | 80 | } | |
| 80 | 81 | d.reapplyThemeAllSessions() | |
| @@ -75,6 +75,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 75 | d.barScripts.mu.Unlock() | 75 | d.barScripts.mu.Unlock() | ||
| 76 | } | 76 | } | ||
| 77 | if barChanged { | 77 | if barChanged { | ||
| 78 | + | d.signalBarPollerReload() | |||
| 78 | d.refreshBarScriptsAllSessions() | 79 | d.refreshBarScriptsAllSessions() | ||
| 79 | } | 80 | } | ||
| 80 | d.reapplyThemeAllSessions() | 81 | d.reapplyThemeAllSessions() | ||
M internal/usecase/daemon/daemon.go +1 -0
| @@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 357 | 357 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 358 | 358 | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 359 | 359 | running: make(map[domain.SessionID]bool), | |
| 360 | + | reload: make(chan struct{}, 1), | |
| 360 | 361 | }, | |
| 361 | 362 | } | |
| 362 | 363 | defaults := domain.Defaults() | |
| @@ -357,6 +357,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 357 | lastRefresh: make(map[domain.SessionID]time.Time), | 357 | lastRefresh: make(map[domain.SessionID]time.Time), | ||
| 358 | lastContext: make(map[domain.SessionID]barScriptContext), | 358 | lastContext: make(map[domain.SessionID]barScriptContext), | ||
| 359 | running: make(map[domain.SessionID]bool), | 359 | running: make(map[domain.SessionID]bool), | ||
| 360 | + | reload: make(chan struct{}, 1), | |||
| 360 | }, | 361 | }, | ||
| 361 | } | 362 | } | ||
| 362 | defaults := domain.Defaults() | 363 | defaults := domain.Defaults() | ||