feat(pty): Linux PTY adapter — ptmx open, setctty spawn, resize, EIO-as-EOF
- commit
- 7ac68301865e78acba819efab2e13aba1cd15576
- author
- brice · · 2026-07-02 11:01 UTC
- parent
- 63e4fd6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +446 -1
M go.mod +4 -1
| @@ -2,7 +2,10 @@ module github.com/bnema/vev | |||
| 2 | 2 | ||
| 3 | 3 | go 1.26 | |
| 4 | 4 | ||
| 5 | - | require github.com/stretchr/testify v1.11.1 | |
| 5 | + | require ( | |
| 6 | + | github.com/stretchr/testify v1.11.1 | |
| 7 | + | golang.org/x/sys v0.46.0 | |
| 8 | + | ) | |
| 6 | 9 | ||
| 7 | 10 | require ( | |
| 8 | 11 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| @@ -2,7 +2,10 @@ module github.com/bnema/vev | |||||
| 2 | 2 | ||||
| 3 | go 1.26 | 3 | go 1.26 | ||
| 4 | 4 | ||||
| 5 | - | require github.com/stretchr/testify v1.11.1 | 5 | + | require ( |
| 6 | + | github.com/stretchr/testify v1.11.1 | |||
| 7 | + | golang.org/x/sys v0.46.0 | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | require ( | 10 | require ( | ||
| 8 | github.com/davecgh/go-spew v1.1.1 // indirect | 11 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
M go.sum +2 -0 generated file: collapsed
A internal/adapters/pty/pty_linux.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package pty | |
| 4 | + | ||
| 5 | + | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | |
| 6 | + | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | |
| 7 | + | // depending on a third-party pty package, keeping the dependency surface at | |
| 8 | + | // x/sys/unix plus the standard library. | |
| 9 | + | ||
| 10 | + | import ( | |
| 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/ports" | |
| 22 | + | "golang.org/x/sys/unix" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // killGracePeriod is how long Close waits for a signalled child to exit before | |
| 26 | + | // escalating from SIGHUP to SIGKILL. | |
| 27 | + | const killGracePeriod = 2 * time.Second | |
| 28 | + | ||
| 29 | + | // Factory implements ports.PTYFactory for Linux. | |
| 30 | + | type Factory struct{} | |
| 31 | + | ||
| 32 | + | // NewFactory returns a Linux PTY factory. | |
| 33 | + | func NewFactory() *Factory { return &Factory{} } | |
| 34 | + | ||
| 35 | + | // Open spawns command with args attached to a freshly allocated pseudo-terminal | |
| 36 | + | // and returns the master side as a ports.PTY. env is passed to the child | |
| 37 | + | // verbatim (nil means inherit the current process environment); the caller | |
| 38 | + | // decides TERM and friends. sz sets the terminal window size before the child | |
| 39 | + | // starts, so the child observes the correct dimensions on its first query. | |
| 40 | + | func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | |
| 41 | + | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Own the master fd until Start succeeds; close it on every error path. | |
| 47 | + | ok := false | |
| 48 | + | defer func() { | |
| 49 | + | if !ok { | |
| 50 | + | _ = unix.Close(masterFd) | |
| 51 | + | } | |
| 52 | + | }() | |
| 53 | + | ||
| 54 | + | ptn, err := unix.IoctlGetInt(masterFd, unix.TIOCGPTN) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, fmt.Errorf("pty: TIOCGPTN: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Unlock the slave so it can be opened. | |
| 60 | + | if err := unix.IoctlSetPointerInt(masterFd, unix.TIOCSPTLCK, 0); err != nil { | |
| 61 | + | return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | slaveName := fmt.Sprintf("/dev/pts/%d", ptn) | |
| 65 | + | slave, err := os.OpenFile(slaveName, os.O_RDWR|unix.O_NOCTTY, 0) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, fmt.Errorf("pty: open %s: %w", slaveName, err) | |
| 68 | + | } | |
| 69 | + | // The child dups the slave into its std fds and holds its own copies, so the | |
| 70 | + | // parent always drops the slave once Start has run (or on any error). | |
| 71 | + | defer func() { _ = slave.Close() }() | |
| 72 | + | ||
| 73 | + | // Set the initial window size on the master before Start so the child's very | |
| 74 | + | // first size query already reflects sz. | |
| 75 | + | if sz.Valid() { | |
| 76 | + | if err := setWinsize(masterFd, sz); err != nil { | |
| 77 | + | return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | cmd := exec.Command(command, args...) | |
| 82 | + | cmd.Env = env | |
| 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 | + | ||
| 95 | + | if err := cmd.Start(); err != nil { | |
| 96 | + | return nil, fmt.Errorf("pty: start %q: %w", command, err) | |
| 97 | + | } | |
| 98 | + | ok = true | |
| 99 | + | ||
| 100 | + | return &linuxPTY{ | |
| 101 | + | master: os.NewFile(uintptr(masterFd), "pty-master"), | |
| 102 | + | cmd: cmd, | |
| 103 | + | }, nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // linuxPTY is a running child attached to the master end of a pty. | |
| 107 | + | type linuxPTY struct { | |
| 108 | + | master *os.File | |
| 109 | + | cmd *exec.Cmd | |
| 110 | + | ||
| 111 | + | waitOnce sync.Once | |
| 112 | + | waitErr error | |
| 113 | + | ||
| 114 | + | closeOnce sync.Once | |
| 115 | + | closeErr error | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | var _ ports.PTY = (*linuxPTY)(nil) | |
| 119 | + | ||
| 120 | + | // Read returns child output read from the master. | |
| 121 | + | // | |
| 122 | + | // When the child (the last process holding the slave open) exits, the kernel | |
| 123 | + | // makes reads of the master fail with EIO. Callers treat "child gone" as a | |
| 124 | + | // normal end of stream, so EIO is mapped to io.EOF here. | |
| 125 | + | func (p *linuxPTY) Read(b []byte) (int, error) { | |
| 126 | + | n, err := p.master.Read(b) | |
| 127 | + | if err != nil && errors.Is(err, unix.EIO) { | |
| 128 | + | return n, io.EOF | |
| 129 | + | } | |
| 130 | + | return n, err | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Write sends input bytes to the child via the master. | |
| 134 | + | func (p *linuxPTY) Write(b []byte) (int, error) { | |
| 135 | + | return p.master.Write(b) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Resize updates the terminal window size, which also delivers SIGWINCH to the | |
| 139 | + | // child's foreground process group. | |
| 140 | + | func (p *linuxPTY) Resize(sz domain.Size) error { | |
| 141 | + | return setWinsize(int(p.master.Fd()), sz) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Pid reports the child process id. | |
| 145 | + | func (p *linuxPTY) Pid() int { | |
| 146 | + | return p.cmd.Process.Pid | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Close terminates the child and releases the master. It signals the child's | |
| 150 | + | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | |
| 151 | + | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | |
| 152 | + | // and the master fd is closed last so any blocked Read unblocks. Close is | |
| 153 | + | // idempotent: subsequent calls are no-ops and return the first result. | |
| 154 | + | func (p *linuxPTY) Close() error { | |
| 155 | + | p.closeOnce.Do(func() { | |
| 156 | + | pid := p.cmd.Process.Pid | |
| 157 | + | ||
| 158 | + | // Negative pid targets the whole process group (the child is the group | |
| 159 | + | // leader thanks to Setsid). Ignore errors: the child may already be gone. | |
| 160 | + | _ = unix.Kill(-pid, unix.SIGHUP) | |
| 161 | + | ||
| 162 | + | reaped := make(chan struct{}) | |
| 163 | + | go func() { | |
| 164 | + | p.wait() | |
| 165 | + | close(reaped) | |
| 166 | + | }() | |
| 167 | + | ||
| 168 | + | select { | |
| 169 | + | case <-reaped: | |
| 170 | + | case <-time.After(killGracePeriod): | |
| 171 | + | _ = unix.Kill(-pid, unix.SIGKILL) | |
| 172 | + | <-reaped | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | p.closeErr = p.master.Close() | |
| 176 | + | }) | |
| 177 | + | return p.closeErr | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // wait reaps the child exactly once, memoizing the exit error. | |
| 181 | + | func (p *linuxPTY) wait() error { | |
| 182 | + | p.waitOnce.Do(func() { p.waitErr = p.cmd.Wait() }) | |
| 183 | + | return p.waitErr | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |
| 187 | + | func setWinsize(fd int, sz domain.Size) error { | |
| 188 | + | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | |
| 189 | + | Row: uint16(sz.Rows), | |
| 190 | + | Col: uint16(sz.Cols), | |
| 191 | + | }) | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package pty | |||
| 4 | + | ||||
| 5 | + | // The Linux adapter drives /dev/ptmx directly (open master, TIOCGPTN to learn | |||
| 6 | + | // the slave index, TIOCSPTLCK to unlock, then open /dev/pts/<N>) rather than | |||
| 7 | + | // depending on a third-party pty package, keeping the dependency surface at | |||
| 8 | + | // x/sys/unix plus the standard library. | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 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/ports" | |||
| 22 | + | "golang.org/x/sys/unix" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // killGracePeriod is how long Close waits for a signalled child to exit before | |||
| 26 | + | // escalating from SIGHUP to SIGKILL. | |||
| 27 | + | const killGracePeriod = 2 * time.Second | |||
| 28 | + | ||||
| 29 | + | // Factory implements ports.PTYFactory for Linux. | |||
| 30 | + | type Factory struct{} | |||
| 31 | + | ||||
| 32 | + | // NewFactory returns a Linux PTY factory. | |||
| 33 | + | func NewFactory() *Factory { return &Factory{} } | |||
| 34 | + | ||||
| 35 | + | // Open spawns command with args attached to a freshly allocated pseudo-terminal | |||
| 36 | + | // and returns the master side as a ports.PTY. env is passed to the child | |||
| 37 | + | // verbatim (nil means inherit the current process environment); the caller | |||
| 38 | + | // decides TERM and friends. sz sets the terminal window size before the child | |||
| 39 | + | // starts, so the child observes the correct dimensions on its first query. | |||
| 40 | + | func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | |||
| 41 | + | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Own the master fd until Start succeeds; close it on every error path. | |||
| 47 | + | ok := false | |||
| 48 | + | defer func() { | |||
| 49 | + | if !ok { | |||
| 50 | + | _ = unix.Close(masterFd) | |||
| 51 | + | } | |||
| 52 | + | }() | |||
| 53 | + | ||||
| 54 | + | ptn, err := unix.IoctlGetInt(masterFd, unix.TIOCGPTN) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, fmt.Errorf("pty: TIOCGPTN: %w", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Unlock the slave so it can be opened. | |||
| 60 | + | if err := unix.IoctlSetPointerInt(masterFd, unix.TIOCSPTLCK, 0); err != nil { | |||
| 61 | + | return nil, fmt.Errorf("pty: TIOCSPTLCK: %w", err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | slaveName := fmt.Sprintf("/dev/pts/%d", ptn) | |||
| 65 | + | slave, err := os.OpenFile(slaveName, os.O_RDWR|unix.O_NOCTTY, 0) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, fmt.Errorf("pty: open %s: %w", slaveName, err) | |||
| 68 | + | } | |||
| 69 | + | // The child dups the slave into its std fds and holds its own copies, so the | |||
| 70 | + | // parent always drops the slave once Start has run (or on any error). | |||
| 71 | + | defer func() { _ = slave.Close() }() | |||
| 72 | + | ||||
| 73 | + | // Set the initial window size on the master before Start so the child's very | |||
| 74 | + | // first size query already reflects sz. | |||
| 75 | + | if sz.Valid() { | |||
| 76 | + | if err := setWinsize(masterFd, sz); err != nil { | |||
| 77 | + | return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | cmd := exec.Command(command, args...) | |||
| 82 | + | cmd.Env = env | |||
| 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 | + | ||||
| 95 | + | if err := cmd.Start(); err != nil { | |||
| 96 | + | return nil, fmt.Errorf("pty: start %q: %w", command, err) | |||
| 97 | + | } | |||
| 98 | + | ok = true | |||
| 99 | + | ||||
| 100 | + | return &linuxPTY{ | |||
| 101 | + | master: os.NewFile(uintptr(masterFd), "pty-master"), | |||
| 102 | + | cmd: cmd, | |||
| 103 | + | }, nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // linuxPTY is a running child attached to the master end of a pty. | |||
| 107 | + | type linuxPTY struct { | |||
| 108 | + | master *os.File | |||
| 109 | + | cmd *exec.Cmd | |||
| 110 | + | ||||
| 111 | + | waitOnce sync.Once | |||
| 112 | + | waitErr error | |||
| 113 | + | ||||
| 114 | + | closeOnce sync.Once | |||
| 115 | + | closeErr error | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | var _ ports.PTY = (*linuxPTY)(nil) | |||
| 119 | + | ||||
| 120 | + | // Read returns child output read from the master. | |||
| 121 | + | // | |||
| 122 | + | // When the child (the last process holding the slave open) exits, the kernel | |||
| 123 | + | // makes reads of the master fail with EIO. Callers treat "child gone" as a | |||
| 124 | + | // normal end of stream, so EIO is mapped to io.EOF here. | |||
| 125 | + | func (p *linuxPTY) Read(b []byte) (int, error) { | |||
| 126 | + | n, err := p.master.Read(b) | |||
| 127 | + | if err != nil && errors.Is(err, unix.EIO) { | |||
| 128 | + | return n, io.EOF | |||
| 129 | + | } | |||
| 130 | + | return n, err | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Write sends input bytes to the child via the master. | |||
| 134 | + | func (p *linuxPTY) Write(b []byte) (int, error) { | |||
| 135 | + | return p.master.Write(b) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Resize updates the terminal window size, which also delivers SIGWINCH to the | |||
| 139 | + | // child's foreground process group. | |||
| 140 | + | func (p *linuxPTY) Resize(sz domain.Size) error { | |||
| 141 | + | return setWinsize(int(p.master.Fd()), sz) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Pid reports the child process id. | |||
| 145 | + | func (p *linuxPTY) Pid() int { | |||
| 146 | + | return p.cmd.Process.Pid | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Close terminates the child and releases the master. It signals the child's | |||
| 150 | + | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | |||
| 151 | + | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | |||
| 152 | + | // and the master fd is closed last so any blocked Read unblocks. Close is | |||
| 153 | + | // idempotent: subsequent calls are no-ops and return the first result. | |||
| 154 | + | func (p *linuxPTY) Close() error { | |||
| 155 | + | p.closeOnce.Do(func() { | |||
| 156 | + | pid := p.cmd.Process.Pid | |||
| 157 | + | ||||
| 158 | + | // Negative pid targets the whole process group (the child is the group | |||
| 159 | + | // leader thanks to Setsid). Ignore errors: the child may already be gone. | |||
| 160 | + | _ = unix.Kill(-pid, unix.SIGHUP) | |||
| 161 | + | ||||
| 162 | + | reaped := make(chan struct{}) | |||
| 163 | + | go func() { | |||
| 164 | + | p.wait() | |||
| 165 | + | close(reaped) | |||
| 166 | + | }() | |||
| 167 | + | ||||
| 168 | + | select { | |||
| 169 | + | case <-reaped: | |||
| 170 | + | case <-time.After(killGracePeriod): | |||
| 171 | + | _ = unix.Kill(-pid, unix.SIGKILL) | |||
| 172 | + | <-reaped | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | p.closeErr = p.master.Close() | |||
| 176 | + | }) | |||
| 177 | + | return p.closeErr | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // wait reaps the child exactly once, memoizing the exit error. | |||
| 181 | + | func (p *linuxPTY) wait() error { | |||
| 182 | + | p.waitOnce.Do(func() { p.waitErr = p.cmd.Wait() }) | |||
| 183 | + | return p.waitErr | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ. | |||
| 187 | + | func setWinsize(fd int, sz domain.Size) error { | |||
| 188 | + | return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{ | |||
| 189 | + | Row: uint16(sz.Rows), | |||
| 190 | + | Col: uint16(sz.Cols), | |||
| 191 | + | }) | |||
| 192 | + | } | |||
A internal/adapters/pty/pty_linux_test.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package pty_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "syscall" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/adapters/pty" | |
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 17 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // readAll drains p until EOF or the deadline, returning what was read. | |
| 22 | + | func readAll(t *testing.T, p ports.PTY, deadline time.Duration) []byte { | |
| 23 | + | t.Helper() | |
| 24 | + | type res struct { | |
| 25 | + | b []byte | |
| 26 | + | err error | |
| 27 | + | } | |
| 28 | + | ch := make(chan res, 1) | |
| 29 | + | go func() { | |
| 30 | + | var buf bytes.Buffer | |
| 31 | + | _, err := io.Copy(&buf, p) | |
| 32 | + | ch <- res{buf.Bytes(), err} | |
| 33 | + | }() | |
| 34 | + | select { | |
| 35 | + | case r := <-ch: | |
| 36 | + | require.NoError(t, r.err) | |
| 37 | + | return r.b | |
| 38 | + | case <-time.After(deadline): | |
| 39 | + | t.Fatal("timed out reading from pty") | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func newFactory() ports.PTYFactory { return pty.NewFactory() } | |
| 45 | + | ||
| 46 | + | func TestFactory_ImplementsPort(t *testing.T) { | |
| 47 | + | var _ ports.PTYFactory = pty.NewFactory() | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestOpen_ChildOutputToEOF(t *testing.T) { | |
| 51 | + | if testing.Short() { | |
| 52 | + | t.Skip("skipping pty integration test in -short mode") | |
| 53 | + | } | |
| 54 | + | f := newFactory() | |
| 55 | + | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 58 | + | ||
| 59 | + | out := readAll(t, p, 5*time.Second) | |
| 60 | + | require.Equal(t, "hello", string(out)) | |
| 61 | + | ||
| 62 | + | require.NoError(t, p.Close()) | |
| 63 | + | // Idempotent second close. | |
| 64 | + | require.NoError(t, p.Close()) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestOpen_EchoRoundtrip(t *testing.T) { | |
| 68 | + | if testing.Short() { | |
| 69 | + | t.Skip("skipping pty integration test in -short mode") | |
| 70 | + | } | |
| 71 | + | f := newFactory() | |
| 72 | + | p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 75 | + | ||
| 76 | + | _, err = io.WriteString(p, "roundtrip\n") | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | ||
| 79 | + | // cat echoes input back on the master; read until we see it. | |
| 80 | + | buf := make([]byte, 256) | |
| 81 | + | deadline := time.Now().Add(5 * time.Second) | |
| 82 | + | var got string | |
| 83 | + | for !strings.Contains(got, "roundtrip") { | |
| 84 | + | if time.Now().After(deadline) { | |
| 85 | + | t.Fatalf("did not read echoed data, got %q", got) | |
| 86 | + | } | |
| 87 | + | n, rerr := p.Read(buf) | |
| 88 | + | got += string(buf[:n]) | |
| 89 | + | if rerr != nil { | |
| 90 | + | require.ErrorIs(t, rerr, io.EOF) | |
| 91 | + | break | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | require.Contains(t, got, "roundtrip") | |
| 95 | + | ||
| 96 | + | // Close terminates the still-running cat. | |
| 97 | + | require.NoError(t, p.Close()) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestResize_SttySize(t *testing.T) { | |
| 101 | + | if testing.Short() { | |
| 102 | + | t.Skip("skipping pty integration test in -short mode") | |
| 103 | + | } | |
| 104 | + | tests := []struct { | |
| 105 | + | name string | |
| 106 | + | initial domain.Size | |
| 107 | + | resize *domain.Size | |
| 108 | + | script string | |
| 109 | + | want string | |
| 110 | + | }{ | |
| 111 | + | { | |
| 112 | + | name: "initial size honored before spawn", | |
| 113 | + | initial: domain.Size{Cols: 80, Rows: 24}, | |
| 114 | + | script: "stty size", | |
| 115 | + | want: "24 80", | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "resize before child reads size", | |
| 119 | + | initial: domain.Size{Cols: 80, Rows: 24}, | |
| 120 | + | resize: &domain.Size{Cols: 120, Rows: 40}, | |
| 121 | + | script: "sleep 0.2; stty size", | |
| 122 | + | want: "40 120", | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | for _, tt := range tests { | |
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |
| 128 | + | f := newFactory() | |
| 129 | + | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 132 | + | ||
| 133 | + | if tt.resize != nil { | |
| 134 | + | require.NoError(t, p.Resize(*tt.resize)) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | out := readAll(t, p, 5*time.Second) | |
| 138 | + | require.Equal(t, tt.want, strings.TrimSpace(string(out))) | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestClose_ReapsChildNoZombie(t *testing.T) { | |
| 144 | + | if testing.Short() { | |
| 145 | + | t.Skip("skipping pty integration test in -short mode") | |
| 146 | + | } | |
| 147 | + | f := newFactory() | |
| 148 | + | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | ||
| 151 | + | pid := p.Pid() | |
| 152 | + | require.Greater(t, pid, 0) | |
| 153 | + | ||
| 154 | + | require.NoError(t, p.Close()) | |
| 155 | + | ||
| 156 | + | // After Close the child must be gone (reaped): signal 0 probes existence. | |
| 157 | + | // A reaped pid yields ESRCH; a zombie would still yield nil here so we also | |
| 158 | + | // confirm /proc/<pid>/status, if present, is not in a running/sleeping state. | |
| 159 | + | err = syscall.Kill(pid, 0) | |
| 160 | + | if err == nil { | |
| 161 | + | // Still visible: ensure it is a zombie's parent-less remnant is not our child. | |
| 162 | + | data, rerr := os.ReadFile("/proc/" + itoa(pid) + "/status") | |
| 163 | + | if rerr == nil { | |
| 164 | + | require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie") | |
| 165 | + | } | |
| 166 | + | } else { | |
| 167 | + | require.ErrorIs(t, err, syscall.ESRCH) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Idempotent. | |
| 171 | + | require.NoError(t, p.Close()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestClose_TerminatesLongRunningChild(t *testing.T) { | |
| 175 | + | if testing.Short() { | |
| 176 | + | t.Skip("skipping pty integration test in -short mode") | |
| 177 | + | } | |
| 178 | + | f := newFactory() | |
| 179 | + | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | pid := p.Pid() | |
| 182 | + | ||
| 183 | + | start := time.Now() | |
| 184 | + | require.NoError(t, p.Close()) | |
| 185 | + | require.Less(t, time.Since(start), 3*time.Second, "Close should not block for the full grace period") | |
| 186 | + | ||
| 187 | + | require.Eventually(t, func() bool { | |
| 188 | + | return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) | |
| 189 | + | }, 3*time.Second, 20*time.Millisecond) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestResize_DeliversSIGWINCH(t *testing.T) { | |
| 193 | + | if testing.Short() { | |
| 194 | + | t.Skip("skipping pty integration test in -short mode") | |
| 195 | + | } | |
| 196 | + | // Child traps WINCH, prints a marker, then exits; the Resize ioctl must | |
| 197 | + | // deliver SIGWINCH to the foreground process group. | |
| 198 | + | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | |
| 199 | + | f := newFactory() | |
| 200 | + | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 203 | + | ||
| 204 | + | // Wait for READY so the trap is installed before we resize. | |
| 205 | + | buf := make([]byte, 256) | |
| 206 | + | deadline := time.Now().Add(5 * time.Second) | |
| 207 | + | var acc string | |
| 208 | + | for !strings.Contains(acc, "READY") { | |
| 209 | + | if time.Now().After(deadline) { | |
| 210 | + | t.Fatalf("child never signaled READY, got %q", acc) | |
| 211 | + | } | |
| 212 | + | n, rerr := p.Read(buf) | |
| 213 | + | acc += string(buf[:n]) | |
| 214 | + | if rerr != nil { | |
| 215 | + | t.Fatalf("read error before READY: %v (got %q)", rerr, acc) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | require.NoError(t, p.Resize(domain.Size{Cols: 100, Rows: 30})) | |
| 220 | + | ||
| 221 | + | for !strings.Contains(acc, "GOTWINCH") { | |
| 222 | + | if time.Now().After(deadline) { | |
| 223 | + | t.Fatalf("child did not report SIGWINCH, got %q", acc) | |
| 224 | + | } | |
| 225 | + | n, rerr := p.Read(buf) | |
| 226 | + | acc += string(buf[:n]) | |
| 227 | + | if rerr != nil { | |
| 228 | + | require.ErrorIs(t, rerr, io.EOF) | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | require.Contains(t, acc, "GOTWINCH") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // itoa avoids importing strconv just for a couple call sites in this file. | |
| 236 | + | func itoa(i int) string { | |
| 237 | + | if i == 0 { | |
| 238 | + | return "0" | |
| 239 | + | } | |
| 240 | + | var b [20]byte | |
| 241 | + | pos := len(b) | |
| 242 | + | for i > 0 { | |
| 243 | + | pos-- | |
| 244 | + | b[pos] = byte('0' + i%10) | |
| 245 | + | i /= 10 | |
| 246 | + | } | |
| 247 | + | return string(b[pos:]) | |
| 248 | + | } | |
| @@ -0,0 +1,248 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package pty_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "syscall" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/adapters/pty" | |||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 17 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | + | "github.com/stretchr/testify/require" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // readAll drains p until EOF or the deadline, returning what was read. | |||
| 22 | + | func readAll(t *testing.T, p ports.PTY, deadline time.Duration) []byte { | |||
| 23 | + | t.Helper() | |||
| 24 | + | type res struct { | |||
| 25 | + | b []byte | |||
| 26 | + | err error | |||
| 27 | + | } | |||
| 28 | + | ch := make(chan res, 1) | |||
| 29 | + | go func() { | |||
| 30 | + | var buf bytes.Buffer | |||
| 31 | + | _, err := io.Copy(&buf, p) | |||
| 32 | + | ch <- res{buf.Bytes(), err} | |||
| 33 | + | }() | |||
| 34 | + | select { | |||
| 35 | + | case r := <-ch: | |||
| 36 | + | require.NoError(t, r.err) | |||
| 37 | + | return r.b | |||
| 38 | + | case <-time.After(deadline): | |||
| 39 | + | t.Fatal("timed out reading from pty") | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func newFactory() ports.PTYFactory { return pty.NewFactory() } | |||
| 45 | + | ||||
| 46 | + | func TestFactory_ImplementsPort(t *testing.T) { | |||
| 47 | + | var _ ports.PTYFactory = pty.NewFactory() | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestOpen_ChildOutputToEOF(t *testing.T) { | |||
| 51 | + | if testing.Short() { | |||
| 52 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 53 | + | } | |||
| 54 | + | f := newFactory() | |||
| 55 | + | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 58 | + | ||||
| 59 | + | out := readAll(t, p, 5*time.Second) | |||
| 60 | + | require.Equal(t, "hello", string(out)) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, p.Close()) | |||
| 63 | + | // Idempotent second close. | |||
| 64 | + | require.NoError(t, p.Close()) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestOpen_EchoRoundtrip(t *testing.T) { | |||
| 68 | + | if testing.Short() { | |||
| 69 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 70 | + | } | |||
| 71 | + | f := newFactory() | |||
| 72 | + | p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 75 | + | ||||
| 76 | + | _, err = io.WriteString(p, "roundtrip\n") | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | ||||
| 79 | + | // cat echoes input back on the master; read until we see it. | |||
| 80 | + | buf := make([]byte, 256) | |||
| 81 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 82 | + | var got string | |||
| 83 | + | for !strings.Contains(got, "roundtrip") { | |||
| 84 | + | if time.Now().After(deadline) { | |||
| 85 | + | t.Fatalf("did not read echoed data, got %q", got) | |||
| 86 | + | } | |||
| 87 | + | n, rerr := p.Read(buf) | |||
| 88 | + | got += string(buf[:n]) | |||
| 89 | + | if rerr != nil { | |||
| 90 | + | require.ErrorIs(t, rerr, io.EOF) | |||
| 91 | + | break | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | require.Contains(t, got, "roundtrip") | |||
| 95 | + | ||||
| 96 | + | // Close terminates the still-running cat. | |||
| 97 | + | require.NoError(t, p.Close()) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestResize_SttySize(t *testing.T) { | |||
| 101 | + | if testing.Short() { | |||
| 102 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 103 | + | } | |||
| 104 | + | tests := []struct { | |||
| 105 | + | name string | |||
| 106 | + | initial domain.Size | |||
| 107 | + | resize *domain.Size | |||
| 108 | + | script string | |||
| 109 | + | want string | |||
| 110 | + | }{ | |||
| 111 | + | { | |||
| 112 | + | name: "initial size honored before spawn", | |||
| 113 | + | initial: domain.Size{Cols: 80, Rows: 24}, | |||
| 114 | + | script: "stty size", | |||
| 115 | + | want: "24 80", | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "resize before child reads size", | |||
| 119 | + | initial: domain.Size{Cols: 80, Rows: 24}, | |||
| 120 | + | resize: &domain.Size{Cols: 120, Rows: 40}, | |||
| 121 | + | script: "sleep 0.2; stty size", | |||
| 122 | + | want: "40 120", | |||
| 123 | + | }, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | for _, tt := range tests { | |||
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 128 | + | f := newFactory() | |||
| 129 | + | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 132 | + | ||||
| 133 | + | if tt.resize != nil { | |||
| 134 | + | require.NoError(t, p.Resize(*tt.resize)) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | out := readAll(t, p, 5*time.Second) | |||
| 138 | + | require.Equal(t, tt.want, strings.TrimSpace(string(out))) | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestClose_ReapsChildNoZombie(t *testing.T) { | |||
| 144 | + | if testing.Short() { | |||
| 145 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 146 | + | } | |||
| 147 | + | f := newFactory() | |||
| 148 | + | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | ||||
| 151 | + | pid := p.Pid() | |||
| 152 | + | require.Greater(t, pid, 0) | |||
| 153 | + | ||||
| 154 | + | require.NoError(t, p.Close()) | |||
| 155 | + | ||||
| 156 | + | // After Close the child must be gone (reaped): signal 0 probes existence. | |||
| 157 | + | // A reaped pid yields ESRCH; a zombie would still yield nil here so we also | |||
| 158 | + | // confirm /proc/<pid>/status, if present, is not in a running/sleeping state. | |||
| 159 | + | err = syscall.Kill(pid, 0) | |||
| 160 | + | if err == nil { | |||
| 161 | + | // Still visible: ensure it is a zombie's parent-less remnant is not our child. | |||
| 162 | + | data, rerr := os.ReadFile("/proc/" + itoa(pid) + "/status") | |||
| 163 | + | if rerr == nil { | |||
| 164 | + | require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie") | |||
| 165 | + | } | |||
| 166 | + | } else { | |||
| 167 | + | require.ErrorIs(t, err, syscall.ESRCH) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Idempotent. | |||
| 171 | + | require.NoError(t, p.Close()) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 175 | + | if testing.Short() { | |||
| 176 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 177 | + | } | |||
| 178 | + | f := newFactory() | |||
| 179 | + | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | pid := p.Pid() | |||
| 182 | + | ||||
| 183 | + | start := time.Now() | |||
| 184 | + | require.NoError(t, p.Close()) | |||
| 185 | + | require.Less(t, time.Since(start), 3*time.Second, "Close should not block for the full grace period") | |||
| 186 | + | ||||
| 187 | + | require.Eventually(t, func() bool { | |||
| 188 | + | return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) | |||
| 189 | + | }, 3*time.Second, 20*time.Millisecond) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestResize_DeliversSIGWINCH(t *testing.T) { | |||
| 193 | + | if testing.Short() { | |||
| 194 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 195 | + | } | |||
| 196 | + | // Child traps WINCH, prints a marker, then exits; the Resize ioctl must | |||
| 197 | + | // deliver SIGWINCH to the foreground process group. | |||
| 198 | + | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | |||
| 199 | + | f := newFactory() | |||
| 200 | + | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 203 | + | ||||
| 204 | + | // Wait for READY so the trap is installed before we resize. | |||
| 205 | + | buf := make([]byte, 256) | |||
| 206 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 207 | + | var acc string | |||
| 208 | + | for !strings.Contains(acc, "READY") { | |||
| 209 | + | if time.Now().After(deadline) { | |||
| 210 | + | t.Fatalf("child never signaled READY, got %q", acc) | |||
| 211 | + | } | |||
| 212 | + | n, rerr := p.Read(buf) | |||
| 213 | + | acc += string(buf[:n]) | |||
| 214 | + | if rerr != nil { | |||
| 215 | + | t.Fatalf("read error before READY: %v (got %q)", rerr, acc) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | require.NoError(t, p.Resize(domain.Size{Cols: 100, Rows: 30})) | |||
| 220 | + | ||||
| 221 | + | for !strings.Contains(acc, "GOTWINCH") { | |||
| 222 | + | if time.Now().After(deadline) { | |||
| 223 | + | t.Fatalf("child did not report SIGWINCH, got %q", acc) | |||
| 224 | + | } | |||
| 225 | + | n, rerr := p.Read(buf) | |||
| 226 | + | acc += string(buf[:n]) | |||
| 227 | + | if rerr != nil { | |||
| 228 | + | require.ErrorIs(t, rerr, io.EOF) | |||
| 229 | + | break | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | require.Contains(t, acc, "GOTWINCH") | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // itoa avoids importing strconv just for a couple call sites in this file. | |||
| 236 | + | func itoa(i int) string { | |||
| 237 | + | if i == 0 { | |||
| 238 | + | return "0" | |||
| 239 | + | } | |||
| 240 | + | var b [20]byte | |||
| 241 | + | pos := len(b) | |||
| 242 | + | for i > 0 { | |||
| 243 | + | pos-- | |||
| 244 | + | b[pos] = byte('0' + i%10) | |||
| 245 | + | i /= 10 | |||
| 246 | + | } | |||
| 247 | + | return string(b[pos:]) | |||
| 248 | + | } | |||