From 310d7b66b33d20c5380a77dc1ffcd4615451ac21 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 13:45:56 +0200 Subject: [PATCH] fix(cat): suppress broken-pipe error when piped into head (#163) When cat pipes into head, head exits after reading enough lines and closes the pipe. cat's next write gets EPIPE, which was being printed to stderr. In bash, SIGPIPE silently terminates the writer. Add IsBrokenPipe helper and use it in cat to silently exit on EPIPE. Closes #163 Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/builtins.go | 10 +++++++ builtins/cat/cat.go | 3 +++ .../pipe/basic/cat_head_no_broken_pipe.yaml | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/scenarios/shell/pipe/basic/cat_head_no_broken_pipe.yaml diff --git a/builtins/builtins.go b/builtins/builtins.go index 23328eb5..332948aa 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -7,11 +7,13 @@ package builtins import ( "context" + "errors" "fmt" "io" "io/fs" "os" "sort" + "syscall" "time" "github.com/spf13/pflag" @@ -199,6 +201,14 @@ func (c *CallContext) Errf(format string, a ...any) { fmt.Fprintf(c.Stderr, format, a...) } +// IsBrokenPipe reports whether err is a broken-pipe (EPIPE) error, +// which occurs when writing to a pipe whose read end has been closed. +// In bash this triggers SIGPIPE which silently terminates the writer; +// builtins should use this to suppress error messages on pipe closure. +func IsBrokenPipe(err error) bool { + return err != nil && errors.Is(err, syscall.EPIPE) +} + // FileID is a comparable file identity for cycle detection. // On Unix: device + inode. On Windows: volume serial + file index. // Used as map key for visited-set tracking. diff --git a/builtins/cat/cat.go b/builtins/cat/cat.go index 9b86473b..79530bd8 100644 --- a/builtins/cat/cat.go +++ b/builtins/cat/cat.go @@ -162,6 +162,9 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { err = catRaw(ctx, callCtx, file) } if err != nil { + if builtins.IsBrokenPipe(err) { + break + } name := file if file == "-" { name = "standard input" diff --git a/tests/scenarios/shell/pipe/basic/cat_head_no_broken_pipe.yaml b/tests/scenarios/shell/pipe/basic/cat_head_no_broken_pipe.yaml new file mode 100644 index 00000000..17f910ed --- /dev/null +++ b/tests/scenarios/shell/pipe/basic/cat_head_no_broken_pipe.yaml @@ -0,0 +1,26 @@ +description: cat piped into head must not emit a broken-pipe error on stderr. +setup: + files: + - path: big.txt + content: |+ + line 1 + line 2 + line 3 + line 4 + line 5 + line 6 + line 7 + line 8 + line 9 + line 10 +input: + allowed_paths: ["$DIR"] + script: |+ + cat big.txt | head -n 3 +expect: + stdout: |+ + line 1 + line 2 + line 3 + stderr: |+ + exit_code: 0