From caef8a3e242887b8e49a0c90c4c2a2d207ddd056 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Thu, 16 Jul 2026 07:46:30 +0000 Subject: [PATCH 1/2] fix: treat PTY hangup as EOF --- pipeline.go | 8 ++--- pty_read_error_other.go | 10 +++++++ pty_read_error_unix.go | 28 ++++++++++++++++++ pty_read_error_unix_test.go | 59 +++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 pty_read_error_other.go create mode 100644 pty_read_error_unix.go create mode 100644 pty_read_error_unix_test.go diff --git a/pipeline.go b/pipeline.go index 9c9f1ec..0672ac0 100644 --- a/pipeline.go +++ b/pipeline.go @@ -105,12 +105,8 @@ func (p *pipeline) start() { if stg.ptyMaster != nil { stg.ptyDone = make(chan error, 1) go func(st *stage) { - _, err := io.Copy(st.ptyWriter, st.ptyMaster) - if err != nil { - st.ptyDone <- err - } else { - st.ptyDone <- nil - } + _, err := io.Copy(st.ptyWriter, ptyOutputReader(st.ptyMaster)) + st.ptyDone <- err _ = st.ptyMaster.Close() }(stg) _ = stg.ptySlave.Close() diff --git a/pty_read_error_other.go b/pty_read_error_other.go new file mode 100644 index 0000000..5b8fad4 --- /dev/null +++ b/pty_read_error_other.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin + +package execx + +import "io" + +// ptyOutputReader preserves native read failures on platforms without Unix PTY hangup semantics. +func ptyOutputReader(reader io.Reader) io.Reader { + return reader +} diff --git a/pty_read_error_unix.go b/pty_read_error_unix.go new file mode 100644 index 0000000..bf01770 --- /dev/null +++ b/pty_read_error_unix.go @@ -0,0 +1,28 @@ +//go:build linux || darwin + +package execx + +import ( + "errors" + "io" + "syscall" +) + +// ptyEOFReader converts the Unix terminal hangup convention without changing other read errors. +type ptyEOFReader struct { + reader io.Reader +} + +// Read treats a Unix PTY hangup as EOF because the slave has already closed normally. +func (r ptyEOFReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if errors.Is(err, syscall.EIO) { + return n, io.EOF + } + return n, err +} + +// ptyOutputReader limits terminal hangup normalization to reads so writer failures remain visible. +func ptyOutputReader(reader io.Reader) io.Reader { + return ptyEOFReader{reader: reader} +} diff --git a/pty_read_error_unix_test.go b/pty_read_error_unix_test.go new file mode 100644 index 0000000..2783d8e --- /dev/null +++ b/pty_read_error_unix_test.go @@ -0,0 +1,59 @@ +//go:build linux || darwin + +package execx + +import ( + "errors" + "io" + "syscall" + "testing" +) + +// ptyErrorReader provides deterministic terminal read failures for the platform adapter tests. +type ptyErrorReader struct { + err error +} + +// Read returns the configured error so PTY EOF handling can be tested without kernel timing. +func (r ptyErrorReader) Read([]byte) (int, error) { + return 0, r.err +} + +// TestPTYEOFReader distinguishes a normal Unix terminal hangup from a real read failure. +func TestPTYEOFReader(t *testing.T) { + reader := ptyOutputReader(ptyErrorReader{err: syscall.EIO}) + if _, err := reader.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatalf("ptyOutputReader(EIO) error = %v, want EOF", err) + } + + want := errors.New("read failed") + reader = ptyOutputReader(ptyErrorReader{err: want}) + if _, err := reader.Read(make([]byte, 1)); !errors.Is(err, want) { + t.Fatalf("ptyOutputReader() error = %v, want %v", err, want) + } +} + +// TestWithPTYTreatsHangupAsEOF exercises the kernel PTY path that pipes cannot reproduce. +func TestWithPTYTreatsHangupAsEOF(t *testing.T) { + result, err := Command("printf", "hello").WithPTY().Run() + if err != nil { + t.Fatalf("WithPTY().Run() error = %v", err) + } + if result.Stdout != "hello" { + t.Fatalf("WithPTY().Run() stdout = %q, want %q", result.Stdout, "hello") + } +} + +// TestWithPTYPreservesExitStatusAfterHangup keeps terminal EOF handling from hiding child failures. +func TestWithPTYPreservesExitStatusAfterHangup(t *testing.T) { + result, err := Command("sh", "-c", "printf failed; exit 7").WithPTY().Run() + if err != nil { + t.Fatalf("WithPTY().Run() error = %v", err) + } + if result.ExitCode != 7 { + t.Fatalf("WithPTY().Run() exit code = %d, want 7", result.ExitCode) + } + if result.Stdout != "failed" { + t.Fatalf("WithPTY().Run() stdout = %q, want %q", result.Stdout, "failed") + } +} From 1b99d32db7807c049e72dc5a1f4d63bbde05d38e Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Thu, 16 Jul 2026 07:48:49 +0000 Subject: [PATCH 2/2] docs: refresh generated test count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a1a6691..1ba11cd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Latest tag - Tests + Tests Go Report Card