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}