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