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