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 "syscall"
19 "time"
20)
21
22const (
23 // MaxFrame bounds one frame; longer lines are truncated.
24 MaxFrame = 16 << 10
25 readSize = 32 << 10
26
27 minBackoff = 500 * time.Millisecond
28 maxBackoff = 30 * time.Second
29 // stableRun is how long a script must run before its backoff resets.
30 stableRun = 10 * time.Second
31)
32
33// Zone is where a module's text is placed.
34type Zone uint8
35
36const (
37 Left Zone = iota
38 Center
39 Right
40)
41
42// Module is one running script.
43type Module struct {
44 Name string
45 Zone Zone
46 Exec string
47 // Env is added to the script's environment, as KEY=value entries.
48 Env []string
49
50 mu sync.Mutex
51 pending []byte // latest complete frame
52 dirty bool
53 failed bool // the script is not running; show an error marker
54
55 wake chan<- struct{}
56 log *slog.Logger
57
58 done sync.WaitGroup // Start adds one; Run releases it when its process is gone
59}
60
61// New creates a module. wake receives a non-blocking signal after each frame.
62func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
63 return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
64 pending: make([]byte, 0, MaxFrame)}
65}
66
67// Take copies the pending frame into dst (reusing its capacity) and clears the
68// dirty flag. changed is false when nothing new arrived. failed reports that
69// the script is down.
70func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
71 m.mu.Lock()
72 defer m.mu.Unlock()
73 failed = m.failed
74 if !m.dirty {
75 return dst, false, failed
76 }
77 m.dirty = false
78 return append(dst[:0], m.pending...), true, failed
79}
80
81func (m *Module) signal() {
82 select {
83 case m.wake <- struct{}{}:
84 default:
85 }
86}
87
88func (m *Module) publish(frame []byte) {
89 m.mu.Lock()
90 m.pending = append(m.pending[:0], frame...)
91 m.dirty = true
92 m.failed = false
93 m.mu.Unlock()
94 m.signal()
95}
96
97func (m *Module) setFailed() {
98 m.mu.Lock()
99 m.failed = true
100 m.dirty = true
101 m.mu.Unlock()
102 m.signal()
103}
104
105// Start runs the module on its own goroutine until ctx ends. Wait returns once
106// the script and the processes it started are gone.
107func (m *Module) Start(ctx context.Context) {
108 m.done.Add(1)
109 go func() {
110 defer m.done.Done()
111 m.Run(ctx)
112 }()
113}
114
115// Wait blocks until a module started with Start has stopped, or timeout passes.
116func (m *Module) Wait(timeout time.Duration) {
117 ch := make(chan struct{})
118 go func() { m.done.Wait(); close(ch) }()
119 select {
120 case <-ch:
121 case <-time.After(timeout):
122 }
123}
124
125// Run starts the script and restarts it with backoff until ctx ends. It
126// blocks, so call it on its own goroutine; use Start to also be able to Wait.
127func (m *Module) Run(ctx context.Context) {
128 backoff := minBackoff
129 for ctx.Err() == nil {
130 started := time.Now()
131 err := m.runOnce(ctx)
132 if ctx.Err() != nil {
133 return
134 }
135 m.log.Warn("module exited", "module", m.Name, "err", err)
136 m.setFailed()
137 if time.Since(started) > stableRun {
138 backoff = minBackoff
139 }
140 select {
141 case <-ctx.Done():
142 return
143 case <-time.After(backoff):
144 }
145 backoff = min(backoff*2, maxBackoff)
146 }
147}
148
149func (m *Module) runOnce(ctx context.Context) error {
150 cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
151 cmd.Stderr = &logWriter{log: m.log, name: m.Name}
152 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
153 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
154 cmd.WaitDelay = 2 * time.Second
155 cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
156 // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
157 // binary that started them, which need not be in $PATH.
158 if exe, err := os.Executable(); err == nil {
159 cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
160 }
161 out, err := cmd.StdoutPipe()
162 if err != nil {
163 return err
164 }
165 if err = cmd.Start(); err != nil {
166 return err
167 }
168 m.readFrames(out)
169 err = cmd.Wait()
170 // The script may have started children that kept running (a "while" loop
171 // in a subshell, a "sleep"). They share the process group: end them too.
172 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
173 return err
174}
175
176// readFrames splits r into frames and publishes each one.
177func (m *Module) readFrames(r io.Reader) {
178 buf := make([]byte, readSize)
179 frame := make([]byte, 0, MaxFrame)
180 for {
181 n, err := r.Read(buf)
182 chunk := buf[:n]
183 for len(chunk) > 0 {
184 i := bytes.IndexAny(chunk, "\n\f")
185 if i < 0 {
186 frame = appendCapped(frame, chunk)
187 break
188 }
189 frame = appendCapped(frame, chunk[:i])
190 m.publish(frame)
191 frame = frame[:0]
192 chunk = chunk[i+1:]
193 }
194 if err != nil {
195 return
196 }
197 }
198}
199
200func appendCapped(dst, src []byte) []byte {
201 if room := MaxFrame - len(dst); len(src) > room {
202 src = src[:max(room, 0)]
203 }
204 return append(dst, src...)
205}
206
207// logWriter forwards a script's stderr lines to the bar's log.
208type logWriter struct {
209 log *slog.Logger
210 name string
211}
212
213func (w *logWriter) Write(p []byte) (int, error) {
214 text := bytes.TrimRight(p, "\n")
215 if len(text) > 1024 {
216 text = append(text[:1024:1024], "..."...)
217 }
218 w.log.Info("module stderr", "module", w.name, "text", string(text))
219 return len(p), nil
220}
221
222// PublishForTest injects a frame as if the script had printed it.
223func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224
225// ScriptDir is the directory of the program a module's command starts, or ""
226// when it has none to watch: a bare name such as "date" is found through $PATH,
227// and a command that cannot be split into words is left alone. A leading "~/"
228// is the home directory, as the shell reads it, and a relative path is taken
229// from the current directory.
230func ScriptDir(command string) string {
231 fields := strings.Fields(command)
232 if len(fields) == 0 || !strings.Contains(fields[0], "/") {
233 return ""
234 }
235 path := fields[0]
236 if rest, ok := strings.CutPrefix(path, "~/"); ok {
237 home, err := os.UserHomeDir()
238 if err != nil {
239 return ""
240 }
241 path = filepath.Join(home, rest)
242 }
243 abs, err := filepath.Abs(path)
244 if err != nil {
245 return ""
246 }
247 return filepath.Dir(abs)
248}