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: writers always replace sess.env with a whole fresh
122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123 // backing array in place, so capturing the header here is safe.
124 env := sess.env
125 attachments := sess.snapshotAttachmentsLocked()
126 ctx.PaneCWD = sess.cwd
127 sess.mu.Unlock()
128 tb := sess.firstTab()
129 if len(attachments) == 0 || tb == nil {
130 return ctx, env, false
131 }
132 tb.mu.Lock()
133 ctx.Tab = tb.stableID
134 ctx.Cols = tb.size.Cols
135 pid := 0
136 if p := tb.focusedPane(); p != nil {
137 ctx.Pane = p.stableID
138 if d != nil && d.procCwd != nil && p.pty != nil {
139 pid = p.pty.Pid()
140 }
141 }
142 tb.mu.Unlock()
143 if pid != 0 && d != nil && d.procCwd != nil {
144 if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
145 ctx.PaneCWD = cwd
146 }
147 }
148 return ctx, env, true
149}
150
151// signalBarPollerReload wakes the poller without blocking. The channel is
152// buffered to one, so a pending signal already conveys "config changed".
153func (d *Daemon) signalBarPollerReload() {
154 if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
155 return
156 }
157 select {
158 case d.barScripts.reload <- struct{}{}:
159 default:
160 }
161}
162
163func (d *Daemon) barScriptPoller(ctx context.Context) {
164 timer := d.clock.NewTimer(d.barScriptInterval())
165 defer func() { timer.Stop() }()
166 for {
167 select {
168 case <-ctx.Done():
169 return
170 case <-d.barScripts.reload:
171 timer.Stop()
172 timer = d.clock.NewTimer(d.barScriptInterval())
173 case now := <-timer.C():
174 for _, sess := range d.sessionsSnapshot() {
175 d.refreshBarScriptsIfDue(sess, now, false)
176 }
177 timer.Stop()
178 timer = d.clock.NewTimer(d.barScriptInterval())
179 }
180 }
181}
182
183// refreshBarScriptsAllSessions forces a bar-script run for every live session.
184// Called after a config change so a new command takes effect immediately rather
185// than leaving the anchor blank until the poller's next tick.
186func (d *Daemon) refreshBarScriptsAllSessions() {
187 if d == nil || d.barScripts == nil || d.clock == nil {
188 return
189 }
190 now := d.clock.Now()
191 for _, sess := range d.sessionsSnapshot() {
192 d.refreshBarScriptsIfDue(sess, now, true)
193 }
194}
195
196func (d *Daemon) sessionsSnapshot() []*session {
197 d.mu.Lock()
198 defer d.mu.Unlock()
199 return sessionsSnapshot(d.sessions)
200}
201
202func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
203 if d == nil || d.barScripts == nil {
204 return
205 }
206 d.barScripts.mu.Lock()
207 defer d.barScripts.mu.Unlock()
208 delete(d.barScripts.outputs, id)
209 delete(d.barScripts.lastRefresh, id)
210 delete(d.barScripts.lastContext, id)
211 delete(d.barScripts.running, id)
212 delete(d.barScripts.pending, id)
213 for _, anchor := range barAnchors {
214 delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
215 }
216}
217
218// shouldLogBarFailure reports whether this failure differs from the last one
219// logged for the same session and anchor, so a persistently broken script
220// warns once instead of every refresh interval.
221func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
222 d.barScripts.mu.Lock()
223 defer d.barScripts.mu.Unlock()
224 d.barScripts.initLocked()
225 key := barScriptFailureKey{id: id, anchor: anchor}
226 if d.barScripts.lastFailure[key] == signature {
227 return false
228 }
229 d.barScripts.lastFailure[key] = signature
230 return true
231}
232
233func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
234 d.barScripts.mu.Lock()
235 defer d.barScripts.mu.Unlock()
236 d.barScripts.initLocked()
237 delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
238}
239
240func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
241 if d == nil || d.barScripts == nil || sess == nil {
242 return false
243 }
244 baseCtx, env, ok := d.collectBarScriptContext(sess, "")
245 if !ok {
246 return false
247 }
248 if len(env) == 0 {
249 env = d.baseEnv
250 }
251 d.barScripts.mu.Lock()
252 d.barScripts.initLocked()
253 if d.barScripts.lastContext[sess.id] != baseCtx {
254 force = true
255 }
256 lastRefresh := d.barScripts.lastRefresh[sess.id]
257 if d.barScripts.running[sess.id] {
258 if force {
259 d.barScripts.pending[sess.id] = true
260 }
261 d.barScripts.mu.Unlock()
262 return false
263 }
264 if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
265 if force {
266 d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
267 }
268 d.barScripts.mu.Unlock()
269 return false
270 }
271 if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
272 d.barScripts.mu.Unlock()
273 return false
274 }
275 cfg := d.barScripts.cfg
276 if cfg.topRight == "" && cfg.bottomRight == "" {
277 d.barScripts.lastRefresh[sess.id] = now
278 d.barScripts.lastContext[sess.id] = baseCtx
279 current := d.barScripts.outputs[sess.id]
280 changed := current.topRight != "" || current.bottomRight != ""
281 delete(d.barScripts.outputs, sess.id)
282 d.barScripts.mu.Unlock()
283 if changed {
284 d.pokeSessionRender(sess)
285 }
286 return false
287 }
288 d.barScripts.running[sess.id] = true
289 d.barScripts.lastRefresh[sess.id] = now
290 d.barScripts.lastContext[sess.id] = baseCtx
291 runner := d.barScripts.runner
292 version := d.barScripts.version
293 d.barScripts.mu.Unlock()
294 go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
295 return true
296}
297
298func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
299 if d.barScripts.pending[sess.id] {
300 return
301 }
302 if d.clock == nil {
303 return
304 }
305 if delay < 0 {
306 delay = 0
307 }
308 d.barScripts.pending[sess.id] = true
309 go func() {
310 ctx := sess.ctx
311 if ctx == nil {
312 ctx = context.Background()
313 }
314 timer := d.clock.NewTimer(delay)
315 select {
316 case <-ctx.Done():
317 timer.Stop()
318 return
319 case <-timer.C():
320 }
321 d.barScripts.mu.Lock()
322 delete(d.barScripts.pending, sess.id)
323 d.barScripts.mu.Unlock()
324 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
325 }()
326}
327
328func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
329 if runner == nil {
330 runner = barScriptRunner{}
331 }
332 ctx := sess.ctx
333 if ctx == nil {
334 ctx = context.Background()
335 }
336 top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
337 bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
338 d.barScripts.mu.Lock()
339 d.barScripts.initLocked()
340 if !d.barScripts.running[sess.id] || d.barScripts.version != version {
341 delete(d.barScripts.running, sess.id)
342 d.barScripts.mu.Unlock()
343 return
344 }
345 current := d.barScripts.outputs[sess.id]
346 changed := false
347 if topOK && current.topRight != top {
348 current.topRight = top
349 changed = true
350 }
351 if bottomOK && current.bottomRight != bottom {
352 current.bottomRight = bottom
353 changed = true
354 }
355 d.barScripts.outputs[sess.id] = current
356 pending := d.barScripts.pending[sess.id]
357 delete(d.barScripts.running, sess.id)
358 delete(d.barScripts.pending, sess.id)
359 d.barScripts.mu.Unlock()
360 if changed {
361 d.pokeSessionRender(sess)
362 }
363 if pending {
364 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
365 }
366}
367
368func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
369 if command == "" {
370 return "", true
371 }
372 base.Anchor = anchor
373 out, err := runner.run(ctx, command, env, base)
374 if err != nil {
375 d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
376 return "", false
377 }
378 d.clearBarFailure(sess.id, anchor)
379 return out, true
380}
381
382// logBarScriptFailure logs a bar script failure. sessionName must come from a
383// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
384// in collectBarScriptContext) since sess.name is mutable via session rename.
385func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
386 if d.log == nil {
387 return
388 }
389 if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
390 return
391 }
392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393 if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
394 attrs = append(attrs, "exit_code", scriptErr.exitCode)
395 if scriptErr.stderr != "" {
396 attrs = append(attrs, "stderr", scriptErr.stderr)
397 }
398 if scriptErr.exitCode == 127 {
399 attrs = append(attrs, "hint",
400 "command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
401 "PATH", pathFromEnv(env))
402 }
403 }
404 d.log.Warn("bar script failed; keeping last good output", attrs...)
405}
406
407func (d *Daemon) pokeSessionRender(sess *session) {
408 if sess == nil {
409 return
410 }
411 // A headless session can still supply a picker preview to an attachment
412 // owned by another session. Its coordinator fans this wake out to those
413 // preview subscribers even though no primary attachment is bound.
414 attachments := sess.snapshotAttachments()
415 if len(attachments) == 0 {
416 if rc := sess.renderCoordinator(); rc != nil {
417 rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
418 }
419 return
420 }
421 for _, ac := range attachments {
422 d.invalidateRender(sess, ac, false, "bar_refresh.go")
423 }
424}