pty_linux_test.go RawHistoryFolder

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}

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