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}