refactor/kvconf 3421f16

launcher.go RawHistoryFolder

1package visualproc
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"os/exec"
10	"slices"
11	"strings"
12	"sync"
13	"syscall"
14	"time"
15
16	"github.com/bnema/neferafk/internal/ports"
17	"github.com/bnema/zerowrap"
18)
19
20const eventBuffer = 8
21const eofGrace = 2 * time.Second
22const writeTimeout = 2 * time.Second
23
24// Launcher starts visual processes with os/exec. Child fd 3 receives commands
25// and fd 4 carries events; both are private anonymous pipes (CLOEXEC on the
26// daemon side). Stdout/stderr are inherited for the journal, stdin is closed.
27type Launcher struct {
28	Executable string
29	Args       []string
30	// Env is the base environment; nil means os.Environ().
31	Env []string
32	Log zerowrap.Logger
33}
34
35func (l *Launcher) Launch(ctx context.Context, spawn ports.VisualSpawn) (ports.VisualSession, error) {
36	cmdR, cmdW, err := os.Pipe()
37	if err != nil {
38		return nil, err
39	}
40	evR, evW, err := os.Pipe()
41	if err != nil {
42		cmdR.Close()
43		cmdW.Close()
44		return nil, err
45	}
46	env := l.Env
47	if env == nil {
48		env = os.Environ()
49	}
50	env = slices.DeleteFunc(slices.Clone(env), func(kv string) bool {
51		name, _, _ := strings.Cut(kv, "=")
52		return slices.Contains(spawn.OmitEnv, name)
53	})
54	cmd := exec.Command(l.Executable, l.Args...)
55	cmd.Env = env
56	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
57	cmd.ExtraFiles = []*os.File{cmdR, evW}
58	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
59	err = cmd.Start()
60	cmdR.Close() // child owns its ends now
61	evW.Close()
62	if err != nil {
63		cmdW.Close()
64		evR.Close()
65		return nil, fmt.Errorf("visual launch: %w", err)
66	}
67	s := &session{cmd: cmd, w: cmdW, r: evR, events: make(chan ports.VisualEvent, eventBuffer), detached: make(chan struct{}), exited: make(chan struct{}), log: l.Log.WithField("component", "visualproc")}
68	go s.reap()
69	go s.pump()
70	return s, nil
71}
72
73type session struct {
74	cmd      *exec.Cmd
75	w, r     *os.File
76	events   chan ports.VisualEvent
77	detached chan struct{}
78	exited   chan struct{} // closed by reap once exitErr is set
79	exitErr  error
80	detach   sync.Once
81	log      zerowrap.Logger
82}
83
84// reap is the only caller of cmd.Wait; it outlives Detach so the child never
85// becomes a zombie of the daemon.
86func (s *session) reap() {
87	s.exitErr = s.cmd.Wait()
88	close(s.exited)
89}
90
91// pump is the only goroutine that reads the event pipe and reaps the child.
92func (s *session) pump() {
93	defer close(s.events)
94	var perr error
95	for {
96		ev, err := ReadEvent(s.r)
97		if err != nil {
98			perr = err
99			break
100		}
101		select {
102		case s.events <- ev:
103		case <-s.detached:
104			return
105		}
106	}
107	s.r.Close()
108	var exit error
109	select {
110	case <-s.exited:
111	case <-s.detached:
112		return
113	case <-time.After(eofGrace):
114		// Closed events (or spoke garbage) yet still running: malfunction.
115		s.killGroup()
116		select {
117		case <-s.exited:
118		case <-s.detached:
119			return
120		}
121	}
122	exit = s.exitErr
123	if exit == nil && perr != nil && !errors.Is(perr, io.EOF) {
124		exit = perr
125	}
126	select {
127	case s.events <- ports.VisualExited{Err: exit}:
128	case <-s.detached:
129	}
130}
131
132// Send never blocks the policy owner for long: a child that stops reading its
133// command pipe is treated as dead by the caller.
134func (s *session) Send(cmd ports.VisualCommand) error {
135	_ = s.w.SetWriteDeadline(time.Now().Add(writeTimeout))
136	return WriteCommand(s.w, cmd)
137}
138func (s *session) Events() <-chan ports.VisualEvent { return s.events }
139
140// killGroup SIGKILLs the child's process group unless the child was already
141// reaped: after Wait its pid (and so the group id) may belong to someone else.
142// The remaining check-to-kill window is bounded by the reaper's Wait return.
143func (s *session) killGroup() {
144	select {
145	case <-s.exited:
146		return
147	default:
148	}
149	if err := syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
150		s.log.Warn().Err(err).Msg("visual kill failed")
151	}
152}
153
154// Close kills the process group; the reaper collects it and pump reports
155// VisualExited. Close is idempotent.
156func (s *session) Close() error {
157	s.killGroup()
158	err := s.w.Close()
159	if err != nil && !errors.Is(err, os.ErrClosed) {
160		s.log.Warn().Err(err).Msg("visual command pipe close failed")
161		return err
162	}
163	return nil
164}
165
166// Detach abandons the child; reap collects it whenever it exits.
167func (s *session) Detach() error {
168	s.detach.Do(func() { close(s.detached) })
169	s.r.Close()
170	return s.w.Close()
171}

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 2

Tags 9

↑↓ moveenter openesc close