detached 747b112

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
4040 pending map[domain.SessionID]bool
4141 lastFailure map[barScriptFailureKey]string
4242 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{}
4347 }
4448
4549 func effectiveBarInterval(d time.Duration) time.Duration {
@@ -134,18 +138,34 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
134138 return ctx, env, true
135139 }
136140
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+
137153 func (d *Daemon) barScriptPoller(ctx context.Context) {
138154 timer := d.clock.NewTimer(d.barScriptInterval())
139- defer timer.Stop()
155+ defer func() { timer.Stop() }()
140156 for {
141157 select {
142158 case <-ctx.Done():
143159 return
160+ case <-d.barScripts.reload:
161+ timer.Stop()
162+ timer = d.clock.NewTimer(d.barScriptInterval())
144163 case now := <-timer.C():
145164 for _, sess := range d.sessionsSnapshot() {
146165 d.refreshBarScriptsIfDue(sess, now, false)
147166 }
148- timer.Reset(d.barScriptInterval())
167+ timer.Stop()
168+ timer = d.clock.NewTimer(d.barScriptInterval())
149169 }
150170 }
151171 }
@@ -40,6 +40,10 @@ type barScriptState struct {
40 pending map[domain.SessionID]bool40 pending map[domain.SessionID]bool
41 lastFailure map[barScriptFailureKey]string41 lastFailure map[barScriptFailureKey]string
42 version uint6442 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, true138 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 return159 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) {
434434 require.Equal(t, "bottom-b", stateB.bottomRight)
435435 }
436436
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+
437456 func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
438457 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
439458 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) {
7575 d.barScripts.mu.Unlock()
7676 }
7777 if barChanged {
78+ d.signalBarPollerReload()
7879 d.refreshBarScriptsAllSessions()
7980 }
8081 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
357357 lastRefresh: make(map[domain.SessionID]time.Time),
358358 lastContext: make(map[domain.SessionID]barScriptContext),
359359 running: make(map[domain.SessionID]bool),
360+ reload: make(chan struct{}, 1),
360361 },
361362 }
362363 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close