1//go:build linux
2
3package pty_test
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "os"
11 "strconv"
12 "strings"
13 "syscall"
14 "testing"
15 "time"
16
17 "github.com/bnema/vev/internal/adapters/pty"
18 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/ports"
20 "github.com/stretchr/testify/require"
21)
22
23// readAll drains p until EOF or the deadline, returning what was read.
24func readAll(t *testing.T, p ports.PTY, deadline time.Duration) []byte {
25 t.Helper()
26 type res struct {
27 b []byte
28 err error
29 }
30 ch := make(chan res, 1)
31 go func() {
32 var buf bytes.Buffer
33 _, err := io.Copy(&buf, p)
34 ch <- res{buf.Bytes(), err}
35 }()
36 select {
37 case r := <-ch:
38 require.NoError(t, r.err)
39 return r.b
40 case <-time.After(deadline):
41 t.Fatal("timed out reading from pty")
42 return nil
43 }
44}
45
46func newFactory() ports.PTYFactory { return pty.NewFactory() }
47
48func TestFactory_ImplementsPort(t *testing.T) {
49 var _ ports.PTYFactory = pty.NewFactory()
50}
51
52func TestOpen_ChildOutputToEOF(t *testing.T) {
53 if testing.Short() {
54 t.Skip("skipping pty integration test in -short mode")
55 }
56 f := newFactory()
57 p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
58 require.NoError(t, err)
59 t.Cleanup(func() { _ = p.Close() })
60
61 out := readAll(t, p, 5*time.Second)
62 require.Equal(t, "hello", string(out))
63
64 require.NoError(t, p.Close())
65 // Idempotent second close.
66 require.NoError(t, p.Close())
67}
68
69func TestOpen_EchoRoundtrip(t *testing.T) {
70 if testing.Short() {
71 t.Skip("skipping pty integration test in -short mode")
72 }
73 f := newFactory()
74 p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
75 require.NoError(t, err)
76 t.Cleanup(func() { _ = p.Close() })
77
78 _, err = io.WriteString(p, "roundtrip\n")
79 require.NoError(t, err)
80
81 // cat echoes input back on the master; read until we see it.
82 buf := make([]byte, 256)
83 deadline := time.Now().Add(5 * time.Second)
84 var got string
85 for !strings.Contains(got, "roundtrip") {
86 if time.Now().After(deadline) {
87 t.Fatalf("did not read echoed data, got %q", got)
88 }
89 n, rerr := p.Read(buf)
90 got += string(buf[:n])
91 if rerr != nil {
92 require.ErrorIs(t, rerr, io.EOF)
93 break
94 }
95 }
96 require.Contains(t, got, "roundtrip")
97
98 // Close terminates the still-running cat.
99 require.NoError(t, p.Close())
100}
101
102func TestForegroundPgid(t *testing.T) {
103 if testing.Short() {
104 t.Skip("skipping pty integration test in -short mode")
105 }
106 f := newFactory()
107 p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
108 require.NoError(t, err)
109 t.Cleanup(func() { _ = p.Close() })
110
111 pgid, err := p.ForegroundPgid()
112 require.NoError(t, err)
113 require.Greater(t, pgid, 0)
114}
115
116func TestResize_SttySize(t *testing.T) {
117 if testing.Short() {
118 t.Skip("skipping pty integration test in -short mode")
119 }
120 tests := []struct {
121 name string
122 initial domain.Size
123 resize *domain.Size
124 script string
125 want string
126 }{
127 {
128 name: "initial size honored before spawn",
129 initial: domain.Size{Cols: 80, Rows: 24},
130 script: "stty size",
131 want: "24 80",
132 },
133 {
134 name: "resize before child reads size",
135 initial: domain.Size{Cols: 80, Rows: 24},
136 resize: &domain.Size{Cols: 120, Rows: 40},
137 script: "sleep 0.2; stty size",
138 want: "40 120",
139 },
140 }
141
142 for _, tt := range tests {
143 t.Run(tt.name, func(t *testing.T) {
144 f := newFactory()
145 p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", domain.Geometry{Size: tt.initial})
146 require.NoError(t, err)
147 t.Cleanup(func() { _ = p.Close() })
148
149 if tt.resize != nil {
150 require.NoError(t, p.Resize(domain.Geometry{Size: *tt.resize}))
151 }
152
153 out := readAll(t, p, 5*time.Second)
154 require.Equal(t, tt.want, strings.TrimSpace(string(out)))
155 })
156 }
157}
158
159func TestClose_ReapsChildNoZombie(t *testing.T) {
160 if testing.Short() {
161 t.Skip("skipping pty integration test in -short mode")
162 }
163 f := newFactory()
164 p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
165 require.NoError(t, err)
166
167 pid := p.Pid()
168 require.Greater(t, pid, 0)
169
170 require.NoError(t, p.Close())
171
172 // After Close the child must be gone (reaped): signal 0 probes existence.
173 // A reaped pid yields ESRCH; a zombie would still yield nil here so we also
174 // confirm /proc/<pid>/status, if present, is not in a running/sleeping state.
175 err = syscall.Kill(pid, 0)
176 if err == nil {
177 // Still visible: ensure it is a zombie's parent-less remnant is not our child.
178 data, rerr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
179 if rerr == nil {
180 require.Contains(t, string(data), "State:\tZ", "child still alive and not a zombie")
181 }
182 } else {
183 require.ErrorIs(t, err, syscall.ESRCH)
184 }
185
186 // Idempotent.
187 require.NoError(t, p.Close())
188}
189
190func TestClose_TerminatesLongRunningChild(t *testing.T) {
191 if testing.Short() {
192 t.Skip("skipping pty integration test in -short mode")
193 }
194 f := newFactory()
195 p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
196 require.NoError(t, err)
197 pid := p.Pid()
198
199 start := time.Now()
200 require.NoError(t, p.Close())
201 require.Less(t, time.Since(start), 3*time.Second, "Close should not block for the full grace period")
202
203 require.Eventually(t, func() bool {
204 return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH)
205 }, 3*time.Second, 20*time.Millisecond)
206}
207
208func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) {
209 if testing.Short() {
210 t.Skip("skipping pty integration test in -short mode")
211 }
212 ctx, cancel := context.WithCancel(context.Background())
213 defer cancel()
214
215 // The background sleep ignores SIGHUP so killing only the session leader
216 // cannot accidentally pass via the terminal's hangup behavior.
217 p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
218 require.NoError(t, err)
219 t.Cleanup(func() { _ = p.Close() })
220
221 buf := make([]byte, 64)
222 n, err := p.Read(buf)
223 require.NoError(t, err)
224 descendantPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
225 require.NoError(t, err)
226
227 cancel()
228
229 // The direct shell may remain as a zombie until Close reaps it; a zombie has
230 // exited, so it and the background sleep must not remain running.
231 require.Eventually(t, func() bool {
232 return processTerminated(p.Pid()) && processTerminated(descendantPID)
233 }, 3*time.Second, 20*time.Millisecond)
234}
235
236func processTerminated(pid int) bool {
237 if errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) {
238 return true
239 }
240 status, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
241 return err != nil || strings.Contains(string(status), "State:\tZ")
242}
243
244func TestResize_DeliversSIGWINCH(t *testing.T) {
245 if testing.Short() {
246 t.Skip("skipping pty integration test in -short mode")
247 }
248 // Child traps WINCH, prints a marker, then exits; the Resize ioctl must
249 // deliver SIGWINCH to the foreground process group.
250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
251 f := newFactory()
252 p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
253 require.NoError(t, err)
254 t.Cleanup(func() { _ = p.Close() })
255
256 // Wait for READY so the trap is installed before we resize.
257 buf := make([]byte, 256)
258 deadline := time.Now().Add(5 * time.Second)
259 var acc string
260 for !strings.Contains(acc, "READY") {
261 if time.Now().After(deadline) {
262 t.Fatalf("child never signaled READY, got %q", acc)
263 }
264 n, rerr := p.Read(buf)
265 acc += string(buf[:n])
266 if rerr != nil {
267 t.Fatalf("read error before READY: %v (got %q)", rerr, acc)
268 }
269 }
270
271 require.NoError(t, p.Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}))
272
273 for !strings.Contains(acc, "GOTWINCH") {
274 if time.Now().After(deadline) {
275 t.Fatalf("child did not report SIGWINCH, got %q", acc)
276 }
277 n, rerr := p.Read(buf)
278 acc += string(buf[:n])
279 if rerr != nil {
280 require.ErrorIs(t, rerr, io.EOF)
281 break
282 }
283 }
284 require.Contains(t, acc, "GOTWINCH")
285}