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}