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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -23,6 +24,11 @@ type barScriptOutputs struct { | |||
| 23 | 24 | bottomRight string | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 27 | + | type barScriptFailureKey struct { | |
| 28 | + | id domain.SessionID | |
| 29 | + | anchor string | |
| 30 | + | } | |
| 31 | + | ||
| 26 | 32 | type barScriptState struct { | |
| 27 | 33 | mu sync.Mutex | |
| 28 | 34 | cfg barScriptConfig | |
| @@ -32,6 +38,7 @@ type barScriptState struct { | |||
| 32 | 38 | lastContext map[domain.SessionID]barScriptContext | |
| 33 | 39 | running map[domain.SessionID]bool | |
| 34 | 40 | pending map[domain.SessionID]bool | |
| 41 | + | lastFailure map[barScriptFailureKey]string | |
| 35 | 42 | version uint64 | |
| 36 | 43 | } | |
| 37 | 44 | ||
| @@ -65,6 +72,9 @@ func (s *barScriptState) initLocked() { | |||
| 65 | 72 | if s.pending == nil { | |
| 66 | 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 | 80 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |
| @@ -161,6 +171,30 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 161 | 171 | delete(d.barScripts.lastContext, id) | |
| 162 | 172 | delete(d.barScripts.running, id) | |
| 163 | 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 | 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 | 293 | if ctx == nil { | |
| 260 | 294 | ctx = context.Background() | |
| 261 | 295 | } | |
| 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) | |
| 264 | 298 | d.barScripts.mu.Lock() | |
| 265 | 299 | d.barScripts.initLocked() | |
| 266 | 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 | 329 | if command == "" { | |
| 296 | 330 | return "", true | |
| 297 | 331 | } | |
| 298 | 332 | base.Anchor = anchor | |
| 299 | 333 | out, err := runner.run(ctx, command, env, base) | |
| 300 | 334 | 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) | |
| 304 | 336 | return "", false | |
| 305 | 337 | } | |
| 338 | + | d.clearBarFailure(sess.id, anchor) | |
| 306 | 339 | 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 | 365 | func (d *Daemon) pokeSessionRender(sess *session) { | |
| 310 | 366 | if sess == nil { | |
| 311 | 367 | 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 string | 24 | 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.Mutex | 33 | mu sync.Mutex | ||
| 28 | cfg barScriptConfig | 34 | cfg barScriptConfig | ||
| @@ -32,6 +38,7 @@ type barScriptState struct { | |||||
| 32 | lastContext map[domain.SessionID]barScriptContext | 38 | lastContext map[domain.SessionID]barScriptContext | ||
| 33 | running map[domain.SessionID]bool | 39 | running map[domain.SessionID]bool | ||
| 34 | pending map[domain.SessionID]bool | 40 | pending map[domain.SessionID]bool | ||
| 41 | + | lastFailure map[barScriptFailureKey]string | |||
| 35 | version uint64 | 42 | 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 "", true | 330 | return "", true | ||
| 297 | } | 331 | } | ||
| 298 | base.Anchor = anchor | 332 | 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 "", false | 336 | return "", false | ||
| 305 | } | 337 | } | ||
| 338 | + | d.clearBarFailure(sess.id, anchor) | |||
| 306 | return out, true | 339 | 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 | return | 367 | return | ||
M internal/usecase/daemon/bar_refresh_test.go +43 -0
| @@ -272,6 +272,49 @@ func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |||
| 272 | 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 | 318 | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |
| 276 | 319 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 277 | 320 | 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -84,7 +85,11 @@ func (r barScriptRunner) run(ctx context.Context, command string, env []string, | |||
| 84 | 85 | } | |
| 85 | 86 | text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit) | |
| 86 | 87 | 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 | + | } | |
| 88 | 93 | } | |
| 89 | 94 | return text, nil | |
| 90 | 95 | } | |
| @@ -182,3 +187,30 @@ func trimUTF8Bytes(s string, limit int) string { | |||
| 182 | 187 | } | |
| 183 | 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 | + | } | |
| @@ -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, err | 88 | + | return text, &barScriptError{ |
| 89 | + | exitCode: res.ExitCode, | |||
| 90 | + | stderr: sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit), | |||
| 91 | + | err: err, | |||
| 92 | + | } | |||
| 88 | } | 93 | } | ||
| 89 | return text, nil | 94 | 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 | + | } | |||