feat/spike 551649b

module.go RawHistoryFolder

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) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close