detached cbdd525

feat(daemon): report actionable bar script failures once per signature

A failing anchor logged only 'exit status 127' and repeated it every refresh
interval. Include the command, exit code, and captured stderr, and for exit 127
name the PATH that was searched. Warn only when the failure signature changes so
a persistently broken script does not flood the log.
commit
cbdd5258509005ff16c4f42d359141b1b7937e1c
author
brice · · 2026-07-20 08:08 UTC
parent
3b5f32b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +138 -7
M internal/usecase/daemon/bar_refresh.go +62 -6
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "time"
78
@@ -23,6 +24,11 @@ type barScriptOutputs struct {
2324 bottomRight string
2425 }
2526
27+type barScriptFailureKey struct {
28+ id domain.SessionID
29+ anchor string
30+}
31+
2632 type barScriptState struct {
2733 mu sync.Mutex
2834 cfg barScriptConfig
@@ -32,6 +38,7 @@ type barScriptState struct {
3238 lastContext map[domain.SessionID]barScriptContext
3339 running map[domain.SessionID]bool
3440 pending map[domain.SessionID]bool
41+ lastFailure map[barScriptFailureKey]string
3542 version uint64
3643 }
3744
@@ -65,6 +72,9 @@ func (s *barScriptState) initLocked() {
6572 if s.pending == nil {
6673 s.pending = make(map[domain.SessionID]bool)
6774 }
75+ if s.lastFailure == nil {
76+ s.lastFailure = make(map[barScriptFailureKey]string)
77+ }
6878 }
6979
7080 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -161,6 +171,30 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
161171 delete(d.barScripts.lastContext, id)
162172 delete(d.barScripts.running, id)
163173 delete(d.barScripts.pending, id)
174+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: "top-right"})
175+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: "bottom-right"})
176+}
177+
178+// shouldLogBarFailure reports whether this failure differs from the last one
179+// logged for the same session and anchor, so a persistently broken script
180+// warns once instead of every refresh interval.
181+func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
182+ d.barScripts.mu.Lock()
183+ defer d.barScripts.mu.Unlock()
184+ d.barScripts.initLocked()
185+ key := barScriptFailureKey{id: id, anchor: anchor}
186+ if d.barScripts.lastFailure[key] == signature {
187+ return false
188+ }
189+ d.barScripts.lastFailure[key] = signature
190+ return true
191+}
192+
193+func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
194+ d.barScripts.mu.Lock()
195+ defer d.barScripts.mu.Unlock()
196+ d.barScripts.initLocked()
197+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
164198 }
165199
166200 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
@@ -259,8 +293,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
259293 if ctx == nil {
260294 ctx = context.Background()
261295 }
262- top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, "top-right", sess.name)
263- bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, "bottom-right", sess.name)
296+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, "top-right", sess)
297+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, "bottom-right", sess)
264298 d.barScripts.mu.Lock()
265299 d.barScripts.initLocked()
266300 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -291,21 +325,43 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
291325 }
292326 }
293327
294-func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor, sessionName string) (string, bool) {
328+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
295329 if command == "" {
296330 return "", true
297331 }
298332 base.Anchor = anchor
299333 out, err := runner.run(ctx, command, env, base)
300334 if err != nil {
301- if d.log != nil {
302- d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
303- }
335+ d.logBarScriptFailure(sess, anchor, command, env, err)
304336 return "", false
305337 }
338+ d.clearBarFailure(sess.id, anchor)
306339 return out, true
307340 }
308341
342+func (d *Daemon) logBarScriptFailure(sess *session, anchor, command string, env []string, err error) {
343+ if d.log == nil {
344+ return
345+ }
346+ if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
347+ return
348+ }
349+ attrs := []any{"anchor", anchor, "session", sess.name, "command", command, "err", err}
350+ var scriptErr *barScriptError
351+ if errors.As(err, &scriptErr) {
352+ attrs = append(attrs, "exit_code", scriptErr.exitCode)
353+ if scriptErr.stderr != "" {
354+ attrs = append(attrs, "stderr", scriptErr.stderr)
355+ }
356+ if scriptErr.exitCode == 127 {
357+ attrs = append(attrs, "hint",
358+ "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
359+ "PATH", pathFromEnv(env))
360+ }
361+ }
362+ d.log.Warn("bar script failed; keeping last good output", attrs...)
363+}
364+
309365 func (d *Daemon) pokeSessionRender(sess *session) {
310366 if sess == nil {
311367 return
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "time"7 "time"
7 8
@@ -23,6 +24,11 @@ type barScriptOutputs struct {
23 bottomRight string24 bottomRight string
24 }25 }
25 26
27+type barScriptFailureKey struct {
28+ id domain.SessionID
29+ anchor string
30+}
31+
26 type barScriptState struct {32 type barScriptState struct {
27 mu sync.Mutex33 mu sync.Mutex
28 cfg barScriptConfig34 cfg barScriptConfig
@@ -32,6 +38,7 @@ type barScriptState struct {
32 lastContext map[domain.SessionID]barScriptContext38 lastContext map[domain.SessionID]barScriptContext
33 running map[domain.SessionID]bool39 running map[domain.SessionID]bool
34 pending map[domain.SessionID]bool40 pending map[domain.SessionID]bool
41+ lastFailure map[barScriptFailureKey]string
35 version uint6442 version uint64
36 }43 }
37 44
@@ -65,6 +72,9 @@ func (s *barScriptState) initLocked() {
65 if s.pending == nil {72 if s.pending == nil {
66 s.pending = make(map[domain.SessionID]bool)73 s.pending = make(map[domain.SessionID]bool)
67 }74 }
75+ if s.lastFailure == nil {
76+ s.lastFailure = make(map[barScriptFailureKey]string)
77+ }
68 }78 }
69 79
70 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {80 func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
@@ -161,6 +171,30 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
161 delete(d.barScripts.lastContext, id)171 delete(d.barScripts.lastContext, id)
162 delete(d.barScripts.running, id)172 delete(d.barScripts.running, id)
163 delete(d.barScripts.pending, id)173 delete(d.barScripts.pending, id)
174+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: "top-right"})
175+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: "bottom-right"})
176+}
177+
178+// shouldLogBarFailure reports whether this failure differs from the last one
179+// logged for the same session and anchor, so a persistently broken script
180+// warns once instead of every refresh interval.
181+func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
182+ d.barScripts.mu.Lock()
183+ defer d.barScripts.mu.Unlock()
184+ d.barScripts.initLocked()
185+ key := barScriptFailureKey{id: id, anchor: anchor}
186+ if d.barScripts.lastFailure[key] == signature {
187+ return false
188+ }
189+ d.barScripts.lastFailure[key] = signature
190+ return true
191+}
192+
193+func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
194+ d.barScripts.mu.Lock()
195+ defer d.barScripts.mu.Unlock()
196+ d.barScripts.initLocked()
197+ delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
164 }198 }
165 199
166 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {200 func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
@@ -259,8 +293,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
259 if ctx == nil {293 if ctx == nil {
260 ctx = context.Background()294 ctx = context.Background()
261 }295 }
262- top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, "top-right", sess.name)296+ top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, "top-right", sess)
263- bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, "bottom-right", sess.name)297+ bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, "bottom-right", sess)
264 d.barScripts.mu.Lock()298 d.barScripts.mu.Lock()
265 d.barScripts.initLocked()299 d.barScripts.initLocked()
266 if !d.barScripts.running[sess.id] || d.barScripts.version != version {300 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
@@ -291,21 +325,43 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
291 }325 }
292 }326 }
293 327
294-func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor, sessionName string) (string, bool) {328+func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
295 if command == "" {329 if command == "" {
296 return "", true330 return "", true
297 }331 }
298 base.Anchor = anchor332 base.Anchor = anchor
299 out, err := runner.run(ctx, command, env, base)333 out, err := runner.run(ctx, command, env, base)
300 if err != nil {334 if err != nil {
301- if d.log != nil {335+ d.logBarScriptFailure(sess, anchor, command, env, err)
302- d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err)
303- }
304 return "", false336 return "", false
305 }337 }
338+ d.clearBarFailure(sess.id, anchor)
306 return out, true339 return out, true
307 }340 }
308 341
342+func (d *Daemon) logBarScriptFailure(sess *session, anchor, command string, env []string, err error) {
343+ if d.log == nil {
344+ return
345+ }
346+ if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
347+ return
348+ }
349+ attrs := []any{"anchor", anchor, "session", sess.name, "command", command, "err", err}
350+ var scriptErr *barScriptError
351+ if errors.As(err, &scriptErr) {
352+ attrs = append(attrs, "exit_code", scriptErr.exitCode)
353+ if scriptErr.stderr != "" {
354+ attrs = append(attrs, "stderr", scriptErr.stderr)
355+ }
356+ if scriptErr.exitCode == 127 {
357+ attrs = append(attrs, "hint",
358+ "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
359+ "PATH", pathFromEnv(env))
360+ }
361+ }
362+ d.log.Warn("bar script failed; keeping last good output", attrs...)
363+}
364+
309 func (d *Daemon) pokeSessionRender(sess *session) {365 func (d *Daemon) pokeSessionRender(sess *session) {
310 if sess == nil {366 if sess == nil {
311 return367 return
M internal/usecase/daemon/bar_refresh_test.go +43 -0
@@ -272,6 +272,49 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
272272 }, time.Second, time.Millisecond)
273273 }
274274
275+func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
276+ var buf bytes.Buffer
277+ var mu sync.Mutex
278+ logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
279+
280+ r := &fakeBarRunner{errs: []error{
281+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")},
282+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-bottom-right: not found", err: errors.New("exit status 127")},
283+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")},
284+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-bottom-right: not found", err: errors.New("exit status 127")},
285+ }}
286+ d := newBarRefreshTestDaemon(r, time.Second)
287+ d.log = logger
288+ d.baseEnv = []string{"PATH=/usr/bin"}
289+ sess := newBarRefreshTestSession()
290+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
291+
292+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
293+ waitBarRefreshIdle(t, d)
294+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
295+ waitBarRefreshIdle(t, d)
296+
297+ mu.Lock()
298+ out := buf.String()
299+ mu.Unlock()
300+
301+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"),
302+ "identical repeated failure should log once")
303+ require.Contains(t, out, "PATH=/usr/bin")
304+ require.Contains(t, out, "not found")
305+}
306+
307+type syncWriter struct {
308+ w *bytes.Buffer
309+ mu *sync.Mutex
310+}
311+
312+func (s *syncWriter) Write(p []byte) (int, error) {
313+ s.mu.Lock()
314+ defer s.mu.Unlock()
315+ return s.w.Write(p)
316+}
317+
275318 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
276319 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
277320 d := newBarRefreshTestDaemon(r, time.Second)
@@ -272,6 +272,49 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
272 }, time.Second, time.Millisecond)272 }, time.Second, time.Millisecond)
273 }273 }
274 274
275+func TestBarScriptFailureLogsOnceUntilSignatureChanges(t *testing.T) {
276+ var buf bytes.Buffer
277+ var mu sync.Mutex
278+ logger := slog.New(slog.NewTextHandler(&syncWriter{w: &buf, mu: &mu}, nil))
279+
280+ r := &fakeBarRunner{errs: []error{
281+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")},
282+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-bottom-right: not found", err: errors.New("exit status 127")},
283+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-top-right: not found", err: errors.New("exit status 127")},
284+ &barScriptError{exitCode: 127, stderr: "sh: vev-bar-bottom-right: not found", err: errors.New("exit status 127")},
285+ }}
286+ d := newBarRefreshTestDaemon(r, time.Second)
287+ d.log = logger
288+ d.baseEnv = []string{"PATH=/usr/bin"}
289+ sess := newBarRefreshTestSession()
290+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
291+
292+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
293+ waitBarRefreshIdle(t, d)
294+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
295+ waitBarRefreshIdle(t, d)
296+
297+ mu.Lock()
298+ out := buf.String()
299+ mu.Unlock()
300+
301+ require.Equal(t, 1, strings.Count(out, "anchor=top-right"),
302+ "identical repeated failure should log once")
303+ require.Contains(t, out, "PATH=/usr/bin")
304+ require.Contains(t, out, "not found")
305+}
306+
307+type syncWriter struct {
308+ w *bytes.Buffer
309+ mu *sync.Mutex
310+}
311+
312+func (s *syncWriter) Write(p []byte) (int, error) {
313+ s.mu.Lock()
314+ defer s.mu.Unlock()
315+ return s.w.Write(p)
316+}
317+
275 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {318 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
276 r := &fakeBarRunner{outs: []string{"top", "bottom"}}319 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
277 d := newBarRefreshTestDaemon(r, time.Second)320 d := newBarRefreshTestDaemon(r, time.Second)
M internal/usecase/daemon/bar_script.go +33 -1
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "strconv"
89 "strings"
910 "time"
@@ -84,7 +85,11 @@ func (r barScriptRunner) run(ctx context.Context, command string, env []string,
8485 }
8586 text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
8687 if err != nil {
87- return text, err
88+ return text, &barScriptError{
89+ exitCode: res.ExitCode,
90+ stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
91+ err: err,
92+ }
8893 }
8994 return text, nil
9095 }
@@ -182,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string {
182187 }
183188 return s[:limit]
184189 }
190+
191+// barScriptError carries the diagnostic detail needed to explain why a bar
192+// script failed: the process exit code and whatever it wrote to stderr.
193+type barScriptError struct {
194+ exitCode int
195+ stderr string
196+ err error
197+}
198+
199+func (e *barScriptError) Error() string {
200+ if e.stderr != "" {
201+ return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
202+ }
203+ return fmt.Sprintf("exit %d", e.exitCode)
204+}
205+
206+func (e *barScriptError) Unwrap() error { return e.err }
207+
208+// pathFromEnv returns the PATH entry's value, or "" when unset.
209+func pathFromEnv(env []string) string {
210+ for _, entry := range env {
211+ if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
212+ return value
213+ }
214+ }
215+ return ""
216+}
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "strconv"8 "strconv"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -84,7 +85,11 @@ func (r barScriptRunner) run(ctx context.Context, command string, env []string,
84 }85 }
85 text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)86 text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
86 if err != nil {87 if err != nil {
87- return text, err88+ return text, &barScriptError{
89+ exitCode: res.ExitCode,
90+ stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
91+ err: err,
92+ }
88 }93 }
89 return text, nil94 return text, nil
90 }95 }
@@ -182,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string {
182 }187 }
183 return s[:limit]188 return s[:limit]
184 }189 }
190+
191+// barScriptError carries the diagnostic detail needed to explain why a bar
192+// script failed: the process exit code and whatever it wrote to stderr.
193+type barScriptError struct {
194+ exitCode int
195+ stderr string
196+ err error
197+}
198+
199+func (e *barScriptError) Error() string {
200+ if e.stderr != "" {
201+ return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
202+ }
203+ return fmt.Sprintf("exit %d", e.exitCode)
204+}
205+
206+func (e *barScriptError) Unwrap() error { return e.err }
207+
208+// pathFromEnv returns the PATH entry's value, or "" when unset.
209+func pathFromEnv(env []string) string {
210+ for _, entry := range env {
211+ if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
212+ return value
213+ }
214+ }
215+ return ""
216+}

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