1// Package module runs scripts that print ANSI frames on stdout.
2//
3// A frame ends at '\n' or '\f'. Only the most recent frame matters: a module
4// that prints faster than the bar draws simply overwrites its pending frame,
5// so a chatty script costs no memory and no allocation.
6package module
7
8import (
9 "bytes"
10 "context"
11 "io"
12 "log/slog"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "syscall"
20 "time"
21)
22
23const (
24 // MaxFrame bounds one frame; longer lines are truncated.
25 MaxFrame = 16 << 10
26 // MaxControl bounds one control line (see ParseControl); longer ones are
27 // dropped.
28 MaxControl = 64 << 10
29 readSize = 32 << 10
30
31 minBackoff = 500 * time.Millisecond
32 maxBackoff = 30 * time.Second
33 // stableRun is how long a script must run before its backoff resets.
34 stableRun = 10 * time.Second
35)
36
37// Zone is where a module's text is placed.
38type Zone uint8
39
40const (
41 Left Zone = iota
42 Center
43 Right
44)
45
46// Module is one running script.
47type Module struct {
48 Name string
49 Zone Zone
50 Exec string
51 // Env is added to the script's environment, as KEY=value entries.
52 Env []string
53 // Interactive gives the script a stdin that carries Send lines. Set it
54 // before Start.
55 Interactive bool
56
57 mu sync.Mutex
58 pending []byte // latest complete frame
59 dirty bool
60 failed bool // the script is not running; show an error marker
61
62 input chan event // lines for the script's stdin
63 control chan []byte // control lines of the script, see TakeControl
64 gen atomic.Uint64 // counts script starts; see Gen
65
66 // overLogged: an oversize control line was already logged at Warn in this
67 // run of the script. Touched by runOnce and readFrames, which never overlap.
68 overLogged bool
69
70 wake chan<- struct{}
71 log *slog.Logger
72
73 done sync.WaitGroup // Start adds one; Run releases it when its process is gone
74}
75
76// maxLine is the longest line Send accepts, without its newline.
77const maxLine = 127
78
79// event is one stdin line, newline included.
80type event struct {
81 b [maxLine + 1]byte
82 n uint8
83}
84
85// New creates a module. wake receives a non-blocking signal after each frame.
86func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
87 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
88 pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)}
89}
90
91// Gen counts the times the script was started. A value that changed means a
92// new process, which knows nothing of the pointer state sent to the old one.
93// It does not allocate.
94func (m *Module) Gen() uint64 { return m.gen.Load() }
95
96// Send queues line (without a newline) for the script's stdin. It never
97// blocks and does not allocate. It reports false when the module is not
98// interactive, the line is longer than 127 bytes, or 32 lines are already
99// waiting (the script is not reading).
100func (m *Module) Send(line []byte) bool {
101 if !m.Interactive || len(line) > maxLine {
102 return false
103 }
104 var ev event
105 n := copy(ev.b[:], line)
106 ev.b[n] = '\n'
107 ev.n = uint8(n + 1)
108 select {
109 case m.input <- ev:
110 return true
111 default:
112 return false
113 }
114}
115
116// Take copies the pending frame into dst (reusing its capacity) and clears the
117// dirty flag. changed is false when nothing new arrived. failed reports that
118// the script is down.
119func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
120 m.mu.Lock()
121 defer m.mu.Unlock()
122 failed = m.failed
123 if !m.dirty {
124 return dst, false, failed
125 }
126 m.dirty = false
127 return append(dst[:0], m.pending...), true, failed
128}
129
130func (m *Module) signal() {
131 select {
132 case m.wake <- struct{}{}:
133 default:
134 }
135}
136
137func (m *Module) publish(frame []byte) {
138 m.mu.Lock()
139 m.pending = append(m.pending[:0], frame...)
140 m.dirty = true
141 m.failed = false
142 m.mu.Unlock()
143 m.signal()
144}
145
146func (m *Module) setFailed() {
147 m.mu.Lock()
148 m.failed = true
149 m.dirty = true
150 m.mu.Unlock()
151 m.signal()
152}
153
154// Start runs the module on its own goroutine until ctx ends. Wait returns once
155// the script and the processes it started are gone.
156func (m *Module) Start(ctx context.Context) {
157 m.done.Add(1)
158 go func() {
159 defer m.done.Done()
160 m.Run(ctx)
161 }()
162}
163
164// Wait blocks until a module started with Start has stopped, or timeout passes.
165func (m *Module) Wait(timeout time.Duration) {
166 ch := make(chan struct{})
167 go func() { m.done.Wait(); close(ch) }()
168 select {
169 case <-ch:
170 case <-time.After(timeout):
171 }
172}
173
174// Run starts the script and restarts it with backoff until ctx ends. It
175// blocks, so call it on its own goroutine; use Start to also be able to Wait.
176func (m *Module) Run(ctx context.Context) {
177 backoff := minBackoff
178 for ctx.Err() == nil {
179 started := time.Now()
180 err := m.runOnce(ctx)
181 if ctx.Err() != nil {
182 return
183 }
184 m.log.Warn("module exited", "module", m.Name, "err", err)
185 m.setFailed()
186 if time.Since(started) > stableRun {
187 backoff = minBackoff
188 }
189 select {
190 case <-ctx.Done():
191 return
192 case <-time.After(backoff):
193 }
194 backoff = min(backoff*2, maxBackoff)
195 }
196}
197
198func (m *Module) runOnce(ctx context.Context) error {
199 m.gen.Add(1)
200 m.overLogged = false // readFrames of the previous run has ended
201 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
202 cmd.Stderr = &logWriter{log: m.log, name: m.Name}
203 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
204 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
205 cmd.WaitDelay = 2 * time.Second
206 cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
207 // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
208 // binary that started them, which need not be in $PATH.
209 if exe, err := os.Executable(); err == nil {
210 cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
211 }
212 out, err := cmd.StdoutPipe()
213 if err != nil {
214 return err
215 }
216 var in io.WriteCloser
217 if m.Interactive {
218 if in, err = cmd.StdinPipe(); err != nil {
219 return err
220 }
221 // Events queued while no script was running are stale.
222 for drained := false; !drained; {
223 select {
224 case <-m.input:
225 default:
226 drained = true
227 }
228 }
229 }
230 if err = cmd.Start(); err != nil {
231 return err
232 }
233 var stopWriter func()
234 if in != nil {
235 stopWriter = m.pumpInput(in)
236 }
237 m.readFrames(out)
238 if stopWriter != nil {
239 stopWriter()
240 }
241 err = cmd.Wait()
242 // The script may have started children that kept running (a "while" loop
243 // in a subshell, a "sleep"). They share the process group: end them too.
244 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
245 return err
246}
247
248// pumpInput writes queued events to w until the returned stop function is
249// called or a write fails. stop waits for the writer and closes w.
250func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
251 done := make(chan struct{})
252 exited := make(chan struct{})
253 go func() {
254 defer close(exited)
255 for {
256 select {
257 case ev := <-m.input:
258 if _, err := w.Write(ev.b[:ev.n]); err != nil {
259 return
260 }
261 case <-done:
262 return
263 }
264 }
265 }()
266 return func() {
267 close(done)
268 // A write blocked on a script that does not read ends when w closes.
269 _ = w.Close()
270 <-exited
271 }
272}
273
274// controlPrefix starts a control line: an OSC 777 sequence of the "neferbar"
275// application, ended by controlEnd (BEL) before the newline.
276const (
277 controlPrefix = "\x1b]777;neferbar;"
278 controlEnd = '\x07'
279)
280
281// TakeControl returns the oldest control line the script printed, the JSON
282// between the prefix and the BEL, or false when there is none. It never blocks.
283func (m *Module) TakeControl() ([]byte, bool) {
284 select {
285 case b := <-m.control:
286 return b, true
287 default:
288 return nil, false
289 }
290}
291
292// queueControl keeps a copy of the JSON of a control line of an interactive
293// module. A full queue drops the
294// line: the bar is not draining it.
295func (m *Module) queueControl(json []byte) {
296 if !m.Interactive {
297 return // only interactive modules open popups: do not keep or parse the line
298 }
299 select {
300 case m.control <- append([]byte(nil), json...):
301 m.signal()
302 default:
303 m.log.Debug("module control queue full; line dropped", "module", m.Name)
304 }
305}
306
307// line handles one complete line: a control line, or else a frame.
308func (m *Module) line(line []byte, over bool) {
309 if bytes.HasPrefix(line, []byte(controlPrefix)) {
310 switch {
311 case over:
312 // A script can repeat this at will: say it once per run.
313 if !m.overLogged {
314 m.overLogged = true
315 m.log.Warn("module control line too long; dropped (shown once per run)", "module", m.Name, "max", MaxControl)
316 } else {
317 m.log.Debug("module control line too long; dropped", "module", m.Name, "max", MaxControl)
318 }
319 return
320 case len(line) > 0 && line[len(line)-1] == controlEnd:
321 m.queueControl(line[len(controlPrefix) : len(line)-1])
322 return
323 }
324 // No BEL: not a control line; it is shown like any text.
325 }
326 m.publish(line[:min(len(line), MaxFrame)])
327}
328
329// readFrames splits r into frames and publishes each one. Control lines go to
330// the control queue instead.
331func (m *Module) readFrames(r io.Reader) {
332 buf := make([]byte, readSize)
333 line := make([]byte, 0, MaxFrame)
334 over := false // the current line is longer than MaxControl
335 for {
336 n, err := r.Read(buf)
337 chunk := buf[:n]
338 for len(chunk) > 0 {
339 i := bytes.IndexAny(chunk, "\n\f")
340 if i < 0 {
341 line, over = appendCapped(line, chunk, over)
342 break
343 }
344 line, over = appendCapped(line, chunk[:i], over)
345 m.line(line, over)
346 line, over = line[:0], false
347 chunk = chunk[i+1:]
348 }
349 if err != nil {
350 return
351 }
352 }
353}
354
355// appendCapped appends src to dst up to MaxControl bytes and reports whether
356// anything was left out.
357func appendCapped(dst, src []byte, over bool) ([]byte, bool) {
358 if room := MaxControl - len(dst); len(src) > room {
359 src, over = src[:max(room, 0)], true
360 }
361 return append(dst, src...), over
362}
363
364// logWriter forwards a script's stderr lines to the bar's log.
365type logWriter struct {
366 log *slog.Logger
367 name string
368}
369
370func (w *logWriter) Write(p []byte) (int, error) {
371 text := bytes.TrimRight(p, "\n")
372 if len(text) > 1024 {
373 text = append(text[:1024:1024], "..."...)
374 }
375 w.log.Info("module stderr", "module", w.name, "text", string(text))
376 return len(p), nil
377}
378
379// SetGenForTest makes the module look as if its script was restarted.
380func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
381
382// DrainForTest empties the queue of Send lines and returns them without their
383// newlines.
384func DrainForTest(m *Module) []string {
385 var out []string
386 for {
387 select {
388 case ev := <-m.input:
389 out = append(out, string(ev.b[:ev.n-1]))
390 default:
391 return out
392 }
393 }
394}
395
396// ReadFramesForTest feeds r to the module as its script's stdout.
397func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) }
398
399// PublishForTest injects a frame as if the script had printed it.
400func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
401
402// ScriptDir is the directory of the program a module's command starts, or ""
403// when it has none to watch: a bare name such as "date" is found through $PATH,
404// and a command that cannot be split into words is left alone. A leading "~/"
405// is the home directory, as the shell reads it, and a relative path is taken
406// from the current directory.
407func ScriptDir(command string) string {
408 fields := strings.Fields(command)
409 if len(fields) == 0 || !strings.Contains(fields[0], "/") {
410 return ""
411 }
412 path := fields[0]
413 if rest, ok := strings.CutPrefix(path, "~/"); ok {
414 home, err := os.UserHomeDir()
415 if err != nil {
416 return ""
417 }
418 path = filepath.Join(home, rest)
419 }
420 abs, err := filepath.Abs(path)
421 if err != nil {
422 return ""
423 }
424 return filepath.Dir(abs)
425}