detached 7ac6830

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
22
33 go 1.26
44
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+)
69
710 require (
811 github.com/davecgh/go-spew v1.1.1 // indirect
@@ -2,7 +2,10 @@ module github.com/bnema/vev
2 2
3 go 1.263 go 1.26
4 4
5-require github.com/stretchr/testify v1.11.15+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 // indirect11 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+}

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