detached 68625c9

feat(pty): unify pty adapter across linux and darwin

commit
68625c9655e58c0bb4c016ec00981c6aa34429fc
author
brice · · 2026-07-16 17:49 UTC
parent
acf9d96
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +131 -14
A internal/adapters/pty/pty_darwin_test.go +117 -0
@@ -0,0 +1,117 @@
1+//go:build darwin
2+
3+package pty_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/pty"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func readAllDarwin(t *testing.T, p ports.PTY, deadline time.Duration) []byte {
21+ t.Helper()
22+ type result struct {
23+ output []byte
24+ err error
25+ }
26+ resultCh := make(chan result, 1)
27+ go func() {
28+ var output bytes.Buffer
29+ _, err := io.Copy(&output, p)
30+ resultCh <- result{output: output.Bytes(), err: err}
31+ }()
32+
33+ select {
34+ case result := <-resultCh:
35+ require.NoError(t, result.err)
36+ return result.output
37+ case <-time.After(deadline):
38+ t.Fatal("timed out reading from pty")
39+ return nil
40+ }
41+}
42+
43+func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
44+ if testing.Short() {
45+ t.Skip("skipping pty integration test in -short mode")
46+ }
47+
48+ factory := pty.NewFactory()
49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = outputPTY.Close() })
52+ require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
53+
54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
55+ require.NoError(t, err)
56+ t.Cleanup(func() { _ = echoPTY.Close() })
57+ _, err = io.WriteString(echoPTY, "roundtrip\n")
58+ require.NoError(t, err)
59+
60+ buf := make([]byte, 256)
61+ deadline := time.Now().Add(5 * time.Second)
62+ var output string
63+ for !strings.Contains(output, "roundtrip") {
64+ if time.Now().After(deadline) {
65+ t.Fatalf("did not read echoed data, got %q", output)
66+ }
67+ n, readErr := echoPTY.Read(buf)
68+ output += string(buf[:n])
69+ if readErr != nil {
70+ require.ErrorIs(t, readErr, io.EOF)
71+ break
72+ }
73+ }
74+ require.Contains(t, output, "roundtrip")
75+}
76+
77+func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
78+ if testing.Short() {
79+ t.Skip("skipping pty integration test in -short mode")
80+ }
81+
82+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "sleep 0.2; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
83+ require.NoError(t, err)
84+ t.Cleanup(func() { _ = p.Close() })
85+
86+ pgid, err := p.ForegroundPgid()
87+ require.NoError(t, err)
88+ require.Greater(t, pgid, 0)
89+ require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))
90+ require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
91+}
92+
93+func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
94+ if testing.Short() {
95+ t.Skip("skipping pty integration test in -short mode")
96+ }
97+
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
101+ require.NoError(t, err)
102+ t.Cleanup(func() { _ = p.Close() })
103+
104+ streamEnded := make(chan error, 1)
105+ go func() {
106+ _, err := io.Copy(io.Discard, p)
107+ streamEnded <- err
108+ }()
109+
110+ cancel()
111+ select {
112+ case err := <-streamEnded:
113+ require.NoError(t, err)
114+ case <-time.After(5 * time.Second):
115+ t.Fatal("pty stream did not end after context cancellation")
116+ }
117+}
@@ -0,0 +1,117 @@
1+//go:build darwin
2+
3+package pty_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "os"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/vev/internal/adapters/pty"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func readAllDarwin(t *testing.T, p ports.PTY, deadline time.Duration) []byte {
21+ t.Helper()
22+ type result struct {
23+ output []byte
24+ err error
25+ }
26+ resultCh := make(chan result, 1)
27+ go func() {
28+ var output bytes.Buffer
29+ _, err := io.Copy(&output, p)
30+ resultCh <- result{output: output.Bytes(), err: err}
31+ }()
32+
33+ select {
34+ case result := <-resultCh:
35+ require.NoError(t, result.err)
36+ return result.output
37+ case <-time.After(deadline):
38+ t.Fatal("timed out reading from pty")
39+ return nil
40+ }
41+}
42+
43+func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
44+ if testing.Short() {
45+ t.Skip("skipping pty integration test in -short mode")
46+ }
47+
48+ factory := pty.NewFactory()
49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = outputPTY.Close() })
52+ require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
53+
54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
55+ require.NoError(t, err)
56+ t.Cleanup(func() { _ = echoPTY.Close() })
57+ _, err = io.WriteString(echoPTY, "roundtrip\n")
58+ require.NoError(t, err)
59+
60+ buf := make([]byte, 256)
61+ deadline := time.Now().Add(5 * time.Second)
62+ var output string
63+ for !strings.Contains(output, "roundtrip") {
64+ if time.Now().After(deadline) {
65+ t.Fatalf("did not read echoed data, got %q", output)
66+ }
67+ n, readErr := echoPTY.Read(buf)
68+ output += string(buf[:n])
69+ if readErr != nil {
70+ require.ErrorIs(t, readErr, io.EOF)
71+ break
72+ }
73+ }
74+ require.Contains(t, output, "roundtrip")
75+}
76+
77+func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
78+ if testing.Short() {
79+ t.Skip("skipping pty integration test in -short mode")
80+ }
81+
82+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "sleep 0.2; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
83+ require.NoError(t, err)
84+ t.Cleanup(func() { _ = p.Close() })
85+
86+ pgid, err := p.ForegroundPgid()
87+ require.NoError(t, err)
88+ require.Greater(t, pgid, 0)
89+ require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))
90+ require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
91+}
92+
93+func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
94+ if testing.Short() {
95+ t.Skip("skipping pty integration test in -short mode")
96+ }
97+
98+ ctx, cancel := context.WithCancel(context.Background())
99+ defer cancel()
100+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
101+ require.NoError(t, err)
102+ t.Cleanup(func() { _ = p.Close() })
103+
104+ streamEnded := make(chan error, 1)
105+ go func() {
106+ _, err := io.Copy(io.Discard, p)
107+ streamEnded <- err
108+ }()
109+
110+ cancel()
111+ select {
112+ case err := <-streamEnded:
113+ require.NoError(t, err)
114+ case <-time.After(5 * time.Second):
115+ t.Fatal("pty stream did not end after context cancellation")
116+ }
117+}
R internal/adapters/pty/pty_linux.go → internal/adapters/pty/pty_unix.go +14 -14 renamed, 91% similar
@@ -1,8 +1,8 @@
1-//go:build linux
1+//go:build linux || darwin
22
33 package pty
44
5-// The Linux adapter drives /dev/ptmx directly and delegates slave preparation
5+// The Unix adapter drives /dev/ptmx directly and delegates slave preparation
66 // to rawterm rather than depending on a third-party pty package, keeping the
77 // dependency surface at the standard library alone.
88
@@ -27,10 +27,10 @@ import (
2727 // escalating from SIGHUP to SIGKILL.
2828 const killGracePeriod = 2 * time.Second
2929
30-// Factory implements ports.PTYFactory for Linux.
30+// Factory implements ports.PTYFactory on supported Unix platforms.
3131 type Factory struct{}
3232
33-// NewFactory returns a Linux PTY factory.
33+// NewFactory returns a Unix PTY factory.
3434 func NewFactory() *Factory { return &Factory{} }
3535
3636 // Open spawns command with args attached to a freshly allocated pseudo-terminal
@@ -105,14 +105,14 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
105105 }
106106 ok = true
107107
108- return &linuxPTY{
108+ return &unixPTY{
109109 master: os.NewFile(uintptr(masterFd), "pty-master"),
110110 cmd: cmd,
111111 }, nil
112112 }
113113
114-// linuxPTY is a running child attached to the master end of a pty.
115-type linuxPTY struct {
114+// unixPTY is a running child attached to the master end of a pty.
115+type unixPTY struct {
116116 master *os.File
117117 cmd *exec.Cmd
118118
@@ -120,14 +120,14 @@ type linuxPTY struct {
120120 closeErr error
121121 }
122122
123-var _ ports.PTY = (*linuxPTY)(nil)
123+var _ ports.PTY = (*unixPTY)(nil)
124124
125125 // Read returns child output read from the master.
126126 //
127127 // When the child (the last process holding the slave open) exits, the kernel
128128 // makes reads of the master fail with EIO. Callers treat "child gone" as a
129129 // normal end of stream, so EIO is mapped to io.EOF here.
130-func (p *linuxPTY) Read(b []byte) (int, error) {
130+func (p *unixPTY) Read(b []byte) (int, error) {
131131 n, err := p.master.Read(b)
132132 if err != nil && errors.Is(err, syscall.EIO) {
133133 return n, io.EOF
@@ -136,7 +136,7 @@ func (p *linuxPTY) Read(b []byte) (int, error) {
136136 }
137137
138138 // Write sends input bytes to the child via the master.
139-func (p *linuxPTY) Write(b []byte) (int, error) {
139+func (p *unixPTY) Write(b []byte) (int, error) {
140140 return p.master.Write(b)
141141 }
142142
@@ -148,7 +148,7 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
148148 // every subsequent Read into a thread-parking syscall and breaking the
149149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
150150 // fails cleanly (os.ErrClosed) after Close.
151-func (p *linuxPTY) Resize(sz domain.Size) error {
151+func (p *unixPTY) Resize(sz domain.Size) error {
152152 rc, err := p.master.SyscallConn()
153153 if err != nil {
154154 return fmt.Errorf("pty: resize: %w", err)
@@ -163,13 +163,13 @@ func (p *linuxPTY) Resize(sz domain.Size) error {
163163 }
164164
165165 // Pid reports the child process id.
166-func (p *linuxPTY) Pid() int {
166+func (p *unixPTY) Pid() int {
167167 return p.cmd.Process.Pid
168168 }
169169
170170 // ForegroundPgid reports the process group currently in the foreground for the
171171 // pty's controlling terminal.
172-func (p *linuxPTY) ForegroundPgid() (int, error) {
172+func (p *unixPTY) ForegroundPgid() (int, error) {
173173 rc, err := p.master.SyscallConn()
174174 if err != nil {
175175 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
@@ -192,7 +192,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
192192 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
193193 // and the master fd is closed last so any blocked Read unblocks. Close is
194194 // idempotent: subsequent calls are no-ops and return the first result.
195-func (p *linuxPTY) Close() error {
195+func (p *unixPTY) Close() error {
196196 p.closeOnce.Do(func() {
197197 pid := p.cmd.Process.Pid
198198
@@ -1,8 +1,8 @@
1-//go:build linux1+//go:build linux || darwin
2 2
3 package pty3 package pty
4 4
5-// The Linux adapter drives /dev/ptmx directly and delegates slave preparation5+// The Unix adapter drives /dev/ptmx directly and delegates slave preparation
6 // to rawterm rather than depending on a third-party pty package, keeping the6 // to rawterm rather than depending on a third-party pty package, keeping the
7 // dependency surface at the standard library alone.7 // dependency surface at the standard library alone.
8 8
@@ -27,10 +27,10 @@ import (
27 // escalating from SIGHUP to SIGKILL.27 // escalating from SIGHUP to SIGKILL.
28 const killGracePeriod = 2 * time.Second28 const killGracePeriod = 2 * time.Second
29 29
30-// Factory implements ports.PTYFactory for Linux.30+// Factory implements ports.PTYFactory on supported Unix platforms.
31 type Factory struct{}31 type Factory struct{}
32 32
33-// NewFactory returns a Linux PTY factory.33+// NewFactory returns a Unix PTY factory.
34 func NewFactory() *Factory { return &Factory{} }34 func NewFactory() *Factory { return &Factory{} }
35 35
36 // Open spawns command with args attached to a freshly allocated pseudo-terminal36 // Open spawns command with args attached to a freshly allocated pseudo-terminal
@@ -105,14 +105,14 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
105 }105 }
106 ok = true106 ok = true
107 107
108- return &linuxPTY{108+ return &unixPTY{
109 master: os.NewFile(uintptr(masterFd), "pty-master"),109 master: os.NewFile(uintptr(masterFd), "pty-master"),
110 cmd: cmd,110 cmd: cmd,
111 }, nil111 }, nil
112 }112 }
113 113
114-// linuxPTY is a running child attached to the master end of a pty.114+// unixPTY is a running child attached to the master end of a pty.
115-type linuxPTY struct {115+type unixPTY struct {
116 master *os.File116 master *os.File
117 cmd *exec.Cmd117 cmd *exec.Cmd
118 118
@@ -120,14 +120,14 @@ type linuxPTY struct {
120 closeErr error120 closeErr error
121 }121 }
122 122
123-var _ ports.PTY = (*linuxPTY)(nil)123+var _ ports.PTY = (*unixPTY)(nil)
124 124
125 // Read returns child output read from the master.125 // Read returns child output read from the master.
126 //126 //
127 // When the child (the last process holding the slave open) exits, the kernel127 // When the child (the last process holding the slave open) exits, the kernel
128 // makes reads of the master fail with EIO. Callers treat "child gone" as a128 // makes reads of the master fail with EIO. Callers treat "child gone" as a
129 // normal end of stream, so EIO is mapped to io.EOF here.129 // normal end of stream, so EIO is mapped to io.EOF here.
130-func (p *linuxPTY) Read(b []byte) (int, error) {130+func (p *unixPTY) Read(b []byte) (int, error) {
131 n, err := p.master.Read(b)131 n, err := p.master.Read(b)
132 if err != nil && errors.Is(err, syscall.EIO) {132 if err != nil && errors.Is(err, syscall.EIO) {
133 return n, io.EOF133 return n, io.EOF
@@ -136,7 +136,7 @@ func (p *linuxPTY) Read(b []byte) (int, error) {
136 }136 }
137 137
138 // Write sends input bytes to the child via the master.138 // Write sends input bytes to the child via the master.
139-func (p *linuxPTY) Write(b []byte) (int, error) {139+func (p *unixPTY) Write(b []byte) (int, error) {
140 return p.master.Write(b)140 return p.master.Write(b)
141 }141 }
142 142
@@ -148,7 +148,7 @@ func (p *linuxPTY) Write(b []byte) (int, error) {
148 // every subsequent Read into a thread-parking syscall and breaking the148 // every subsequent Read into a thread-parking syscall and breaking the
149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
150 // fails cleanly (os.ErrClosed) after Close.150 // fails cleanly (os.ErrClosed) after Close.
151-func (p *linuxPTY) Resize(sz domain.Size) error {151+func (p *unixPTY) Resize(sz domain.Size) error {
152 rc, err := p.master.SyscallConn()152 rc, err := p.master.SyscallConn()
153 if err != nil {153 if err != nil {
154 return fmt.Errorf("pty: resize: %w", err)154 return fmt.Errorf("pty: resize: %w", err)
@@ -163,13 +163,13 @@ func (p *linuxPTY) Resize(sz domain.Size) error {
163 }163 }
164 164
165 // Pid reports the child process id.165 // Pid reports the child process id.
166-func (p *linuxPTY) Pid() int {166+func (p *unixPTY) Pid() int {
167 return p.cmd.Process.Pid167 return p.cmd.Process.Pid
168 }168 }
169 169
170 // ForegroundPgid reports the process group currently in the foreground for the170 // ForegroundPgid reports the process group currently in the foreground for the
171 // pty's controlling terminal.171 // pty's controlling terminal.
172-func (p *linuxPTY) ForegroundPgid() (int, error) {172+func (p *unixPTY) ForegroundPgid() (int, error) {
173 rc, err := p.master.SyscallConn()173 rc, err := p.master.SyscallConn()
174 if err != nil {174 if err != nil {
175 return 0, fmt.Errorf("pty: foreground pgid: %w", err)175 return 0, fmt.Errorf("pty: foreground pgid: %w", err)
@@ -192,7 +192,7 @@ func (p *linuxPTY) ForegroundPgid() (int, error) {
192 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,192 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
193 // and the master fd is closed last so any blocked Read unblocks. Close is193 // and the master fd is closed last so any blocked Read unblocks. Close is
194 // idempotent: subsequent calls are no-ops and return the first result.194 // idempotent: subsequent calls are no-ops and return the first result.
195-func (p *linuxPTY) Close() error {195+func (p *unixPTY) Close() error {
196 p.closeOnce.Do(func() {196 p.closeOnce.Do(func() {
197 pid := p.cmd.Process.Pid197 pid := p.cmd.Process.Pid
198 198

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