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 supplies the child environment
38// (nil means inherit the current process environment). TERM is selected from
39// the host's available terminfo entries; other entries are preserved. geometry
40// sets the terminal window geometry before the child's first query.
41func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
42	if err := ctx.Err(); err != nil {
43		return nil, err
44	}
45	dir = platform.DirOrHome(dir)
46	env = paneEnvironment(ctx, env, dir)
47	if err := ctx.Err(); err != nil {
48		return nil, err
49	}
50	masterFd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
51	if err != nil {
52		return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
53	}
54
55	// Own the master fd until Start succeeds; close it on every error path.
56	ok := false
57	defer func() {
58		if !ok {
59			_ = syscall.Close(masterFd)
60		}
61	}()
62
63	slave, err := rawterm.PreparePty(masterFd)
64	if err != nil {
65		return nil, fmt.Errorf("pty: prepare pty: %w", err)
66	}
67	// The child dups the slave into its std fds and holds its own copies, so the
68	// parent always drops the slave once Start has run (or on any error).
69	defer func() { _ = slave.Close() }()
70
71	// Set the initial window geometry on the master before Start so the child's
72	// very first size query already reflects the authoritative geometry.
73	geometry = geometry.NormalizePixels()
74	if geometry.Valid() {
75		if err := setGeometry(masterFd, geometry); err != nil {
76			return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err)
77		}
78	}
79
80	cmd := exec.CommandContext(ctx, command, args...)
81	cmd.Env = env
82	cmd.Dir = dir
83	cmd.Stdin = slave
84	cmd.Stdout = slave
85	cmd.Stderr = slave
86	cmd.SysProcAttr = &syscall.SysProcAttr{
87		// New session (child becomes session and process-group leader) and make
88		// the slave its controlling terminal. Ctty is the child-side descriptor
89		// index; the slave is wired to fd 0 above, which is also the default.
90		Setsid:  true,
91		Setctty: true,
92		Ctty:    0,
93	}
94	// CommandContext's default cancellation kills only the direct child. The
95	// child leads a process group, so cancellation must target that group too.
96	cmd.Cancel = func() error {
97		if cmd.Process == nil {
98			return os.ErrProcessDone
99		}
100		if err := signalProcessGroup(cmd.Process.Pid, syscall.SIGKILL); err != nil {
101			if errors.Is(err, syscall.ESRCH) {
102				return os.ErrProcessDone
103			}
104			return err
105		}
106		return nil
107	}
108
109	if err := cmd.Start(); err != nil {
110		return nil, fmt.Errorf("pty: start %q: %w", command, err)
111	}
112	ok = true
113
114	return &unixPTY{
115		master: os.NewFile(uintptr(masterFd), "pty-master"),
116		cmd:    cmd,
117	}, nil
118}
119
120// unixPTY is a running child attached to the master end of a pty.
121type unixPTY struct {
122	master *os.File
123	cmd    *exec.Cmd
124
125	closeOnce sync.Once
126	closeErr  error
127}
128
129var _ ports.PTY = (*unixPTY)(nil)
130
131// Read returns child output read from the master.
132//
133// When the child (the last process holding the slave open) exits, the kernel
134// makes reads of the master fail with EIO. Callers treat "child gone" as a
135// normal end of stream, so EIO is mapped to io.EOF here.
136func (p *unixPTY) Read(b []byte) (int, error) {
137	n, err := p.master.Read(b)
138	if err != nil && errors.Is(err, syscall.EIO) {
139		return n, io.EOF
140	}
141	return n, err
142}
143
144// Write sends input bytes to the child via the master.
145func (p *unixPTY) Write(b []byte) (int, error) {
146	return p.master.Write(b)
147}
148
149// Resize updates the terminal window size, which also delivers SIGWINCH to the
150// child's foreground process group.
151//
152// The ioctl goes through SyscallConn rather than os.File.Fd(): Fd() would flip
153// the master to blocking mode and detach it from the runtime poller, turning
154// every subsequent Read into a thread-parking syscall and breaking the
155// Close-unblocks-Read behavior. Control also pins the fd for the duration and
156// fails cleanly (os.ErrClosed) after Close.
157func (p *unixPTY) Resize(geometry domain.Geometry) error {
158	geometry = geometry.NormalizePixels()
159	rc, err := p.master.SyscallConn()
160	if err != nil {
161		return fmt.Errorf("pty: resize: %w", err)
162	}
163	var ioctlErr error
164	if err := rc.Control(func(fd uintptr) {
165		ioctlErr = setGeometry(int(fd), geometry)
166	}); err != nil {
167		return fmt.Errorf("pty: resize: %w", err)
168	}
169	return ioctlErr
170}
171
172// Pid reports the child process id.
173func (p *unixPTY) Pid() int {
174	return p.cmd.Process.Pid
175}
176
177// ForegroundPgid reports the process group currently in the foreground for the
178// pty's controlling terminal.
179func (p *unixPTY) ForegroundPgid() (int, error) {
180	rc, err := p.master.SyscallConn()
181	if err != nil {
182		return 0, fmt.Errorf("pty: foreground pgid: %w", err)
183	}
184	var pgid int
185	var ioctlErr error
186	if err := rc.Control(func(fd uintptr) {
187		pgid, ioctlErr = rawterm.ForegroundProcessGroup(int(fd))
188	}); err != nil {
189		return 0, fmt.Errorf("pty: foreground pgid: %w", err)
190	}
191	if ioctlErr != nil {
192		return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr)
193	}
194	return pgid, nil
195}
196
197// Close terminates the child and releases the master. It signals the child's
198// process group with SIGHUP, waits up to killGracePeriod for it to exit, then
199// escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
200// and the master fd is closed last so any blocked Read unblocks. Close is
201// idempotent: subsequent calls are no-ops and return the first result.
202func (p *unixPTY) Close() error {
203	p.closeOnce.Do(func() {
204		pid := p.cmd.Process.Pid
205
206		// Ignore errors: the child may already be gone.
207		_ = signalProcessGroup(pid, syscall.SIGHUP)
208
209		// Reap the child (exit status is intentionally discarded; the port has no
210		// Wait — "child gone" surfaces to callers as io.EOF on Read).
211		reaped := make(chan struct{})
212		go func() {
213			_ = p.cmd.Wait()
214			close(reaped)
215		}()
216
217		select {
218		case <-reaped:
219		case <-time.After(killGracePeriod):
220			_ = signalProcessGroup(pid, syscall.SIGKILL)
221			<-reaped
222		}
223
224		p.closeErr = p.master.Close()
225	})
226	return p.closeErr
227}
228
229// signalProcessGroup signals the process group led by pid. Open creates each
230// child as its own session and process-group leader, so a negative pid reaches
231// both the command and every descendant that remains in its process group.
232func signalProcessGroup(pid int, signal syscall.Signal) error {
233	return syscall.Kill(-pid, signal)
234}
235
236// setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
237func setWinsize(fd int, sz domain.Size) error {
238	return setGeometry(fd, domain.Geometry{Size: sz})
239}
240
241// setGeometry applies cell dimensions and any pixel dimensions reported by the
242// geometry authority. Zero pixel dimensions deliberately preserve the kernel's
243// explicit unknown value instead of fabricating a cell-derived size.
244func setGeometry(fd int, geometry domain.Geometry) error {
245	return rawterm.SetWinsizeFull(fd, rawterm.Winsize{
246		Col:    uint16(geometry.Cols),
247		Row:    uint16(geometry.Rows),
248		Xpixel: uint16(geometry.PixelWidth),
249		Ypixel: uint16(geometry.PixelHeight),
250	})
251}

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