1package daemon
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10)
11
12type barScriptExecutor interface {
13 run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error)
14}
15
16// Anchor names, exported to bar scripts as VEV_ANCHOR and documented in
17// docs/configuration.md. barAnchors must list every anchor so cleanup in
18// clearBarScriptsForSession stays in sync with the anchors run in
19// runBarScripts.
20const (
21 barAnchorTopRight = "top-right"
22 barAnchorBottomRight = "bottom-right"
23)
24
25var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight}
26
27type barScriptConfig struct {
28 topRight string
29 bottomRight string
30 interval time.Duration
31}
32
33type barScriptOutputs struct {
34 topRight string
35 bottomRight string
36}
37
38type barScriptFailureKey struct {
39 id domain.SessionID
40 anchor string
41}
42
43type barScriptState struct {
44 mu sync.Mutex
45 cfg barScriptConfig
46 runner barScriptExecutor
47 outputs map[domain.SessionID]barScriptOutputs
48 lastRefresh map[domain.SessionID]time.Time
49 lastContext map[domain.SessionID]barScriptContext
50 running map[domain.SessionID]bool
51 pending map[domain.SessionID]bool
52 lastFailure map[barScriptFailureKey]string
53 version uint64
54
55 // reload wakes barScriptPoller so an interval change takes effect without
56 // waiting out the timer armed under the previous interval.
57 reload chan struct{}
58}
59
60func effectiveBarInterval(d time.Duration) time.Duration {
61 if d <= 0 {
62 return 5 * time.Second
63 }
64 if d < domain.MinBarInterval {
65 return domain.MinBarInterval
66 }
67 return d
68}
69
70func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
71 return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
72}
73
74func (s *barScriptState) initLocked() {
75 if s.outputs == nil {
76 s.outputs = make(map[domain.SessionID]barScriptOutputs)
77 }
78 if s.lastRefresh == nil {
79 s.lastRefresh = make(map[domain.SessionID]time.Time)
80 }
81 if s.lastContext == nil {
82 s.lastContext = make(map[domain.SessionID]barScriptContext)
83 }
84 if s.running == nil {
85 s.running = make(map[domain.SessionID]bool)
86 }
87 if s.pending == nil {
88 s.pending = make(map[domain.SessionID]bool)
89 }
90 if s.lastFailure == nil {
91 s.lastFailure = make(map[barScriptFailureKey]string)
92 }
93}
94
95func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
96 if d == nil || d.barScripts == nil || sess == nil {
97 return "", ""
98 }
99 d.barScripts.mu.Lock()
100 defer d.barScripts.mu.Unlock()
101 out := d.barScripts.outputs[sess.id]
102 return out.topRight, out.bottomRight
103}
104
105func (d *Daemon) barScriptInterval() time.Duration {
106 if d == nil || d.barScripts == nil {
107 return 5 * time.Second
108 }
109 d.barScripts.mu.Lock()
110 defer d.barScripts.mu.Unlock()
111 return effectiveBarInterval(d.barScripts.cfg.interval)
112}
113
114func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) {
115 ctx := barScriptContext{Anchor: anchor}
116 if sess == nil {
117 return ctx, nil, false
118 }
119 sess.mu.Lock()
120 ctx.Session = sess.name
121 // No copy needed: every writer replaces sess.env with a whole fresh slice
122 // under sess.mu and nothing mutates the backing array in place, so
123 // capturing the header here is safe.
124 env := sess.env
125 attachments := sess.snapshotAttachmentsLocked()
126 ctx.PaneCWD = sess.cwd
127 sess.mu.Unlock()
128 ctx.ClientPID = barClientPID(attachments)
129 tb := sess.firstTab()
130 if len(attachments) == 0 || tb == nil {
131 return ctx, env, false
132 }
133 tb.mu.Lock()
134 ctx.Tab = tb.stableID
135 ctx.Cols = tb.size.Cols
136 pid := 0
137 if p := tb.focusedPane(); p != nil {
138 ctx.Pane = p.stableID
139 if d != nil && d.procCwd != nil && p.pty != nil {
140 pid = p.pty.Pid()
141 }
142 }
143 tb.mu.Unlock()
144 if pid != 0 && d != nil && d.procCwd != nil {
145 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
146 ctx.PaneCWD = cwd
147 }
148 }
149 return ctx, env, true
150}
151
152// barClientPID picks the local client a bar script should describe: the one
153// whose terminal has focus, else the first local one. Remote clients carry no
154// PID, so a session attached only remotely yields 0.
155func barClientPID(attachments []*attachedClient) uint32 {
156 var first uint32
157 for _, ac := range attachments {
158 pid := ac.clientPID.Load()
159 if pid == 0 {
160 continue
161 }
162 if ac.terminalFocus() == domain.TerminalFocusFocused {
163 return pid
164 }
165 if first == 0 {
166 first = pid
167 }
168 }
169 return first
170}
171
172// signalBarPollerReload wakes the poller without blocking. The channel is
173// buffered to one, so a pending signal already conveys "config changed".
174func (d *Daemon) signalBarPollerReload() {
175 if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
176 return
177 }
178 select {
179 case d.barScripts.reload <- struct{}{}:
180 default:
181 }
182}
183
184func (d *Daemon) barScriptPoller(ctx context.Context) {
185 timer := d.clock.NewTimer(d.barScriptInterval())
186 defer func() { timer.Stop() }()
187 for {
188 select {
189 case <-ctx.Done():
190 return
191 case <-d.barScripts.reload:
192 timer.Stop()
193 timer = d.clock.NewTimer(d.barScriptInterval())
194 case now := <-timer.C():
195 for _, sess := range d.sessionsSnapshot() {
196 d.refreshBarScriptsIfDue(sess, now, false)
197 }
198 timer.Stop()
199 timer = d.clock.NewTimer(d.barScriptInterval())
200 }
201 }
202}
203
204// refreshBarScriptsAllSessions forces a bar-script run for every live session.
205// Called after a config change so a new command takes effect immediately rather
206// than leaving the anchor blank until the poller's next tick.
207func (d *Daemon) refreshBarScriptsAllSessions() {
208 if d == nil || d.barScripts == nil || d.clock == nil {
209 return
210 }
211 now := d.clock.Now()
212 for _, sess := range d.sessionsSnapshot() {
213 d.refreshBarScriptsIfDue(sess, now, true)
214 }
215}
216
217func (d *Daemon) sessionsSnapshot() []*session {
218 d.mu.Lock()
219 defer d.mu.Unlock()
220 return sessionsSnapshot(d.sessions)
221}
222
223func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
224 if d == nil || d.barScripts == nil {
225 return
226 }
227 d.barScripts.mu.Lock()
228 defer d.barScripts.mu.Unlock()
229 delete(d.barScripts.outputs, id)
230 delete(d.barScripts.lastRefresh, id)
231 delete(d.barScripts.lastContext, id)
232 delete(d.barScripts.running, id)
233 delete(d.barScripts.pending, id)
234 for _, anchor := range barAnchors {
235 delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
236 }
237}
238
239// shouldLogBarFailure reports whether this failure differs from the last one
240// logged for the same session and anchor, so a persistently broken script
241// warns once instead of every refresh interval.
242func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
243 d.barScripts.mu.Lock()
244 defer d.barScripts.mu.Unlock()
245 d.barScripts.initLocked()
246 key := barScriptFailureKey{id: id, anchor: anchor}
247 if d.barScripts.lastFailure[key] == signature {
248 return false
249 }
250 d.barScripts.lastFailure[key] = signature
251 return true
252}
253
254func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
255 d.barScripts.mu.Lock()
256 defer d.barScripts.mu.Unlock()
257 d.barScripts.initLocked()
258 delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
259}
260
261func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
262 if d == nil || d.barScripts == nil || sess == nil {
263 return false
264 }
265 baseCtx, env, ok := d.collectBarScriptContext(sess, "")
266 if !ok {
267 return false
268 }
269 if len(env) == 0 {
270 env = d.baseEnv
271 }
272 d.barScripts.mu.Lock()
273 d.barScripts.initLocked()
274 if d.barScripts.lastContext[sess.id] != baseCtx {
275 force = true
276 }
277 lastRefresh := d.barScripts.lastRefresh[sess.id]
278 if d.barScripts.running[sess.id] {
279 if force {
280 d.barScripts.pending[sess.id] = true
281 }
282 d.barScripts.mu.Unlock()
283 return false
284 }
285 if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
286 if force {
287 d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
288 }
289 d.barScripts.mu.Unlock()
290 return false
291 }
292 if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
293 d.barScripts.mu.Unlock()
294 return false
295 }
296 cfg := d.barScripts.cfg
297 if cfg.topRight == "" && cfg.bottomRight == "" {
298 d.barScripts.lastRefresh[sess.id] = now
299 d.barScripts.lastContext[sess.id] = baseCtx
300 current := d.barScripts.outputs[sess.id]
301 changed := current.topRight != "" || current.bottomRight != ""
302 delete(d.barScripts.outputs, sess.id)
303 d.barScripts.mu.Unlock()
304 if changed {
305 d.pokeSessionRender(sess)
306 }
307 return false
308 }
309 d.barScripts.running[sess.id] = true
310 d.barScripts.lastRefresh[sess.id] = now
311 d.barScripts.lastContext[sess.id] = baseCtx
312 runner := d.barScripts.runner
313 version := d.barScripts.version
314 d.barScripts.mu.Unlock()
315 go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
316 return true
317}
318
319func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
320 if d.barScripts.pending[sess.id] {
321 return
322 }
323 if d.clock == nil {
324 return
325 }
326 if delay < 0 {
327 delay = 0
328 }
329 d.barScripts.pending[sess.id] = true
330 go func() {
331 ctx := sess.ctx
332 if ctx == nil {
333 ctx = context.Background()
334 }
335 timer := d.clock.NewTimer(delay)
336 select {
337 case <-ctx.Done():
338 timer.Stop()
339 return
340 case <-timer.C():
341 }
342 d.barScripts.mu.Lock()
343 delete(d.barScripts.pending, sess.id)
344 d.barScripts.mu.Unlock()
345 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
346 }()
347}
348
349func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
350 if runner == nil {
351 runner = barScriptRunner{}
352 }
353 ctx := sess.ctx
354 if ctx == nil {
355 ctx = context.Background()
356 }
357 top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
358 bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
359 d.barScripts.mu.Lock()
360 d.barScripts.initLocked()
361 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
362 delete(d.barScripts.running, sess.id)
363 d.barScripts.mu.Unlock()
364 return
365 }
366 current := d.barScripts.outputs[sess.id]
367 changed := false
368 if topOK && current.topRight != top {
369 current.topRight = top
370 changed = true
371 }
372 if bottomOK && current.bottomRight != bottom {
373 current.bottomRight = bottom
374 changed = true
375 }
376 d.barScripts.outputs[sess.id] = current
377 pending := d.barScripts.pending[sess.id]
378 delete(d.barScripts.running, sess.id)
379 delete(d.barScripts.pending, sess.id)
380 d.barScripts.mu.Unlock()
381 if changed {
382 d.pokeSessionRender(sess)
383 }
384 if pending {
385 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
386 }
387}
388
389func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
390 if command == "" {
391 return "", true
392 }
393 base.Anchor = anchor
394 out, err := runner.run(ctx, command, env, base)
395 if err != nil {
396 d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
397 return "", false
398 }
399 d.clearBarFailure(sess.id, anchor)
400 return out, true
401}
402
403// logBarScriptFailure logs a bar script failure. sessionName must come from a
404// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
405// in collectBarScriptContext) since sess.name is mutable via session rename.
406func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
407 if d.log == nil {
408 return
409 }
410 if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
411 return
412 }
413 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
414 if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
415 attrs = append(attrs, "exit_code", scriptErr.exitCode)
416 if scriptErr.stderr != "" {
417 attrs = append(attrs, "stderr", scriptErr.stderr)
418 }
419 if scriptErr.exitCode == 127 {
420 attrs = append(attrs, "hint",
421 "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
422 "PATH", pathFromEnv(env))
423 }
424 }
425 d.log.Warn("bar script failed; keeping last good output", attrs...)
426}
427
428func (d *Daemon) pokeSessionRender(sess *session) {
429 if sess == nil {
430 return
431 }
432 // A headless session can still supply a picker preview to an attachment
433 // owned by another session. Its coordinator fans this wake out to those
434 // preview subscribers even though no primary attachment is bound.
435 attachments := sess.snapshotAttachments()
436 if len(attachments) == 0 {
437 if rc := sess.renderCoordinator(); rc != nil {
438 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
439 }
440 return
441 }
442 for _, ac := range attachments {
443 d.invalidateRender(sess, ac, false, "bar_refresh.go")
444 }
445}