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.proc != nil && p.pty != nil {
140 pid = p.pty.Pid()
141 }
142 }
143 tb.mu.Unlock()
144 if pid != 0 && d != nil && d.proc != nil {
145 if cwd, err := d.proc.Cwd(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
319// scheduleBarScriptRefreshLocked requires d.barScripts.mu, not d.mu.
320func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
321 if d.barScripts.pending[sess.id] {
322 return
323 }
324 if d.clock == nil {
325 return
326 }
327 if delay < 0 {
328 delay = 0
329 }
330 d.barScripts.pending[sess.id] = true
331 go func() {
332 ctx := sess.ctx
333 if ctx == nil {
334 ctx = context.Background()
335 }
336 timer := d.clock.NewTimer(delay)
337 select {
338 case <-ctx.Done():
339 timer.Stop()
340 return
341 case <-timer.C():
342 }
343 d.barScripts.mu.Lock()
344 delete(d.barScripts.pending, sess.id)
345 d.barScripts.mu.Unlock()
346 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
347 }()
348}
349
350func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
351 if runner == nil {
352 runner = barScriptRunner{}
353 }
354 ctx := sess.ctx
355 if ctx == nil {
356 ctx = context.Background()
357 }
358 top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
359 bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
360 d.barScripts.mu.Lock()
361 d.barScripts.initLocked()
362 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
363 delete(d.barScripts.running, sess.id)
364 d.barScripts.mu.Unlock()
365 return
366 }
367 current := d.barScripts.outputs[sess.id]
368 changed := false
369 if topOK && current.topRight != top {
370 current.topRight = top
371 changed = true
372 }
373 if bottomOK && current.bottomRight != bottom {
374 current.bottomRight = bottom
375 changed = true
376 }
377 d.barScripts.outputs[sess.id] = current
378 pending := d.barScripts.pending[sess.id]
379 delete(d.barScripts.running, sess.id)
380 delete(d.barScripts.pending, sess.id)
381 d.barScripts.mu.Unlock()
382 if changed {
383 d.pokeSessionRender(sess)
384 }
385 if pending {
386 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
387 }
388}
389
390func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
391 if command == "" {
392 return "", true
393 }
394 base.Anchor = anchor
395 out, err := runner.run(ctx, command, env, base)
396 if err != nil {
397 d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
398 return "", false
399 }
400 d.clearBarFailure(sess.id, anchor)
401 return out, true
402}
403
404// logBarScriptFailure logs a bar script failure. sessionName must come from a
405// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
406// in collectBarScriptContext) since sess.name is mutable via session rename.
407func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
408 if d.log == nil {
409 return
410 }
411 if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
412 return
413 }
414 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
415 if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
416 attrs = append(attrs, "exit_code", scriptErr.exitCode)
417 if scriptErr.stderr != "" {
418 attrs = append(attrs, "stderr", scriptErr.stderr)
419 }
420 if scriptErr.exitCode == 127 {
421 attrs = append(attrs, "hint",
422 "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
423 "PATH", pathFromEnv(env))
424 }
425 }
426 d.log.Warn("bar script failed; keeping last good output", attrs...)
427}
428
429func (d *Daemon) pokeSessionRender(sess *session) {
430 if sess == nil {
431 return
432 }
433 // A headless session can still supply a picker preview to an attachment
434 // owned by another session. Its coordinator fans this wake out to those
435 // preview subscribers even though no primary attachment is bound.
436 attachments := sess.snapshotAttachments()
437 if len(attachments) == 0 {
438 if rc := sess.renderCoordinator(); rc != nil {
439 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
440 }
441 return
442 }
443 for _, ac := range attachments {
444 d.invalidateRender(sess, ac, false, "bar_refresh.go")
445 }
446}