v0.3.0 99dc266

pty_unix.go RawHistoryFolder

1//go:build linux || darwin
2
3package pty
4
5// The Unix adapter drives /dev/ptmx directly and delegates slave preparation
6// to rawterm rather than depending on a third-party pty package, keeping the
7// dependency surface at the standard library alone.
8
9import (
10	"context"
11	"errors"
12	"fmt"
13	"io"
14	"os"
15	"os/exec"
16	"sync"
17	"syscall"
18	"time"
19
20	"github.com/bnema/vev/internal/domain"
21	"github.com/bnema/vev/internal/platform"
22	"github.com/bnema/vev/internal/ports"
23	"github.com/bnema/vev/pkg/rawterm"
24)
25
26// killGracePeriod is how long Close waits for a signalled child to exit before
27// escalating from SIGHUP to SIGKILL.
28const killGracePeriod = 2 * time.Second
29
30// Factory implements ports.PTYFactory on supported Unix platforms.
31type Factory struct{}
32
33// NewFactory returns a Unix PTY factory.
34func NewFactory() *Factory { return &Factory{} }
35
36// Open spawns command with args attached to a freshly allocated pseudo-terminal
37// and returns the master side as a ports.PTY. env is passed to the child
38// verbatim (nil means inherit the current process environment); the caller
39// decides TERM and friends. sz sets the terminal window size before the child
40// starts, so the child observes the correct dimensions on its first query.
41func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
42	if err := ctx.Err(); err != nil {
43		return nil, err
44	}
45	masterFd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
46	if err != nil {
47		return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
48	}
49
50	// Own the master fd until Start succeeds; close it on every error path.
51	ok := false
52	defer func() {
53		if !ok {
54			_ = syscall.Close(masterFd)
55		}
56	}()
57
58	slave, err := rawterm.PreparePty(masterFd)
59	if err != nil {
60		return nil, fmt.Errorf("pty: prepare pty: %w", err)
61	}
62	// The child dups the slave into its std fds and holds its own copies, so the
63	// parent always drops the slave once Start has run (or on any error).
64	defer func() { _ = slave.Close() }()
65
66	// Set the initial window size on the master before Start so the child's very
67	// first size query already reflects sz.
68	if sz.Valid() {
69		if err := setWinsize(masterFd, sz); err != nil {
70			return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err)
71		}
72	}
73
74	cmd := exec.CommandContext(ctx, command, args...)
75	cmd.Env = env
76	cmd.Dir = platform.DirOrHome(dir)
77	cmd.Stdin = slave
78	cmd.Stdout = slave
79	cmd.Stderr = slave
80	cmd.SysProcAttr = &syscall.SysProcAttr{
81		// New session (child becomes session and process-group leader) and make
82		// the slave its controlling terminal. Ctty is the child-side descriptor
83		// index; the slave is wired to fd 0 above, which is also the default.
84		Setsid:  true,
85		Setctty: true,
86		Ctty:    0,
87	}
88	// CommandContext's default cancellation kills only the direct child. The
89	// child leads a process group, so cancellation must target that group too.
90	cmd.Cancel = func() error {
91		if cmd.Process == nil {
92			return os.ErrProcessDone
93		}
94		if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil {
95			if errors.Is(err, syscall.ESRCH) {
96				return os.ErrProcessDone
97			}
98			return err
99		}
100		return nil
101	}
102
103	if err := cmd.Start(); err != nil {
104		return nil, fmt.Errorf("pty: start %q: %w", command, err)
105	}
106	ok = true
107
108	return &unixPTY{
109		master: os.NewFile(uintptr(masterFd), "pty-master"),
110		cmd:    cmd,
111	}, nil
112}
113
114// unixPTY is a running child attached to the master end of a pty.
115type unixPTY struct {
116	master *os.File
117	cmd    *exec.Cmd
118
119	closeOnce sync.Once
120	closeErr  error
121}
122
123var _ ports.PTY = (*unixPTY)(nil)
124
125// Read returns child output read from the master.
126//
127// When the child (the last process holding the slave open) exits, the kernel
128// makes reads of the master fail with EIO. Callers treat "child gone" as a
129// normal end of stream, so EIO is mapped to io.EOF here.
130func (p *unixPTY) Read(b []byte) (int, error) {
131	n, err := p.master.Read(b)
132	if err != nil && errors.Is(err, syscall.EIO) {
133		return n, io.EOF
134	}
135	return n, err
136}
137
138// Write sends input bytes to the child via the master.
139func (p *unixPTY) Write(b []byte) (int, error) {
140	return p.master.Write(b)
141}
142
143// Resize updates the terminal window size, which also delivers SIGWINCH to the
144// child's foreground process group.
145//
146// The ioctl goes through SyscallConn rather than os.File.Fd(): Fd() would flip
147// the master to blocking mode and detach it from the runtime poller, turning
148// every subsequent Read into a thread-parking syscall and breaking the
149// Close-unblocks-Read behavior. Control also pins the fd for the duration and
150// fails cleanly (os.ErrClosed) after Close.
151func (p *unixPTY) Resize(sz domain.Size) error {
152	rc, err := p.master.SyscallConn()
153	if err != nil {
154		return fmt.Errorf("pty: resize: %w", err)
155	}
156	var ioctlErr error
157	if err := rc.Control(func(fd uintptr) {
158		ioctlErr = setWinsize(int(fd), sz)
159	}); err != nil {
160		return fmt.Errorf("pty: resize: %w", err)
161	}
162	return ioctlErr
163}
164
165// Pid reports the child process id.
166func (p *unixPTY) Pid() int {
167	return p.cmd.Process.Pid
168}
169
170// ForegroundPgid reports the process group currently in the foreground for the
171// pty's controlling terminal.
172func (p *unixPTY) ForegroundPgid() (int, error) {
173	rc, err := p.master.SyscallConn()
174	if err != nil {
175		return 0, fmt.Errorf("pty: foreground pgid: %w", err)
176	}
177	var pgid int
178	var ioctlErr error
179	if err := rc.Control(func(fd uintptr) {
180		pgid, ioctlErr = rawterm.ForegroundProcessGroup(int(fd))
181	}); err != nil {
182		return 0, fmt.Errorf("pty: foreground pgid: %w", err)
183	}
184	if ioctlErr != nil {
185		return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr)
186	}
187	return pgid, nil
188}
189
190// Close terminates the child and releases the master. It signals the child's
191// process group with SIGHUP, waits up to killGracePeriod for it to exit, then
192// escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
193// and the master fd is closed last so any blocked Read unblocks. Close is
194// idempotent: subsequent calls are no-ops and return the first result.
195func (p *unixPTY) Close() error {
196	p.closeOnce.Do(func() {
197		pid := p.cmd.Process.Pid
198
199		// Ignore errors: the child may already be gone.
200		_ = signalProcessGroup(pid, syscall.SIGHUP)
201
202		// Reap the child (exit status is intentionally discarded; the port has no
203		// Wait — "child gone" surfaces to callers as io.EOF on Read).
204		reaped := make(chan struct{})
205		go func() {
206			_ = p.cmd.Wait()
207			close(reaped)
208		}()
209
210		select {
211		case <-reaped:
212		case <-time.After(killGracePeriod):
213			_ = signalProcessGroup(pid, syscall.SIGKILL)
214			<-reaped
215		}
216
217		p.closeErr = p.master.Close()
218	})
219	return p.closeErr
220}
221
222// signalProcessGroup signals the process group led by pid. Open creates each
223// child as its own session and process-group leader, so a negative pid reaches
224// both the command and every descendant that remains in its process group.
225func signalProcessGroup(pid int, signal syscall.Signal) error {
226	return syscall.Kill(-pid, signal)
227}
228
229// setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
230func setWinsize(fd int, sz domain.Size) error {
231	return rawterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))
232}

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 12

Tags 11

↑↓ moveenter openesc close