Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,17 @@ func NewCmdOptions(options Options, name string, args ...string) *Cmd {
if options.Buffered {
c.stdoutBuf = NewOutputBuffer()
c.stderrBuf = NewOutputBuffer()
if options.LineBufferSize > 0 {
c.stdoutBuf.SetScannerBufferSize(int(options.LineBufferSize))
c.stderrBuf.SetScannerBufferSize(int(options.LineBufferSize))
}
}

if options.CombinedOutput {
c.stdoutBuf = NewOutputBuffer()
if options.LineBufferSize > 0 {
c.stdoutBuf.SetScannerBufferSize(int(options.LineBufferSize))
}
c.stderrBuf = nil
}

Expand Down Expand Up @@ -239,11 +246,18 @@ func NewCmdOptions(options Options, name string, args ...string) *Cmd {
// of the original object is lost. Cmd is one-use only, so if you need to restart
// a Cmd, you need to Clone it.
func (c *Cmd) Clone() *Cmd {
var lineBufferSize uint
if c.stdoutBuf != nil {
lineBufferSize = uint(c.stdoutBuf.scannerBufSize)
} else if c.stdoutStream != nil {
lineBufferSize = uint(c.stdoutStream.bufSize)
}
clone := NewCmdOptions(
Options{
Buffered: c.stdoutBuf != nil,
CombinedOutput: c.stdoutBuf != nil,
Streaming: c.stdoutStream != nil,
LineBufferSize: lineBufferSize,
},
c.Name,
c.Args...,
Expand Down Expand Up @@ -375,11 +389,16 @@ func (c *Cmd) Status() Status {
if !c.final {
if c.stdoutBuf != nil {
c.status.Stdout = c.stdoutBuf.Lines()
if err := c.stdoutBuf.Err(); err != nil && c.status.Error == nil {
c.status.Error = err
}
c.stdoutBuf = nil // release buffers

}
if c.stderrBuf != nil {
c.status.Stderr = c.stderrBuf.Lines()
if err := c.stderrBuf.Err(); err != nil && c.status.Error == nil {
c.status.Error = err
}
c.stderrBuf = nil // release buffers
}
c.final = true
Expand Down Expand Up @@ -582,20 +601,41 @@ func (c *Cmd) run(in io.Reader) {
// While runnableCmd is running, call stdout.Lines() to read all output
// currently written.
type OutputBuffer struct {
buf *bytes.Buffer
lines []string
buf *bytes.Buffer
lines []string
scannerBufSize int
scanErr error
*sync.Mutex
}

// NewOutputBuffer creates a new output buffer. The buffer is unbounded and safe
// for multiple goroutines to read while the command is running by calling Lines.
func NewOutputBuffer() *OutputBuffer {
out := &OutputBuffer{
buf: &bytes.Buffer{},
lines: []string{},
Mutex: &sync.Mutex{},
return &OutputBuffer{
buf: &bytes.Buffer{},
lines: []string{},
scannerBufSize: bufio.MaxScanTokenSize,
Mutex: &sync.Mutex{},
}
return out
}

// SetScannerBufferSize sets the maximum token size for the bufio.Scanner used
// by Lines(). The default is bufio.MaxScanTokenSize (64KB). Increase this value
// if Lines() truncates output because a line exceeds that limit (Err() will
// return a non-nil error in that case). Must be called before the command starts.
func (rw *OutputBuffer) SetScannerBufferSize(n int) {
rw.Lock()
rw.scannerBufSize = n
rw.Unlock()
}

// Err returns the last error set by Lines(), if any. A non-nil error indicates
// that output was truncated because a line exceeded the scanner buffer size.
// Use SetScannerBufferSize to increase the limit.
func (rw *OutputBuffer) Err() error {
rw.Lock()
defer rw.Unlock()
return rw.scanErr
}

// Write makes OutputBuffer implement the io.Writer interface. Do not call
Expand All @@ -610,14 +650,22 @@ func (rw *OutputBuffer) Write(p []byte) (n int, err error) {
// Lines returns lines of output written by the Cmd. It is safe to call while
// the Cmd is running and after it has finished. Subsequent calls returns more
// lines, if more lines were written. "\r\n" are stripped from the lines.
//
// If a line exceeds the scanner buffer size (set via SetScannerBufferSize,
// default bufio.MaxScanTokenSize / 64KB), scanning stops and Err() returns
// the error. Use SetScannerBufferSize to increase the limit.
func (rw *OutputBuffer) Lines() []string {
rw.Lock()
// Scanners are io.Readers which effectively destroy the buffer by reading
// to EOF. So once we scan the buf to lines, the buf is empty again.
s := bufio.NewScanner(rw.buf)
s.Buffer(make([]byte, rw.scannerBufSize), rw.scannerBufSize)
for s.Scan() {
rw.lines = append(rw.lines, s.Text())
}
if err := s.Err(); err != nil {
rw.scanErr = err
}
rw.Unlock()
return rw.lines
}
Expand Down
123 changes: 122 additions & 1 deletion cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"testing"
"time"

"github.com/go-cmd/cmd"
"github.com/orus-io/cmd"
"github.com/go-test/deep"
)

Expand Down Expand Up @@ -1479,3 +1479,124 @@ func TestCmdLineBufferIncrease(t *testing.T) {
}
<-catStderrStatus
}

func TestOutputBufferDefaultScannerBufSize(t *testing.T) {
// Default scanner buffer size is bufio.MaxScanTokenSize (64KB).
// A line within that limit must be returned correctly.
buf := cmd.NewOutputBuffer()
line := bytes.Repeat([]byte("x"), 1000)
line = append(line, '\n')
buf.Write(line)
lines := buf.Lines()
if len(lines) != 1 {
t.Fatalf("expected 1 line, got %d", len(lines))
}
if len(lines[0]) != 1000 {
t.Errorf("expected line of 1000 bytes, got %d", len(lines[0]))
}
if err := buf.Err(); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}

func TestOutputBufferLongLineTruncation(t *testing.T) {
// A line exceeding bufio.MaxScanTokenSize (64KB) with the default buffer
// must set Err() to a non-nil error, and Lines() must stop at that point.
buf := cmd.NewOutputBuffer()

longLine := bytes.Repeat([]byte("x"), 65*1024) // 65KB > 64KB default
longLine = append(longLine, '\n')
after := []byte("after\n")
buf.Write(longLine)
buf.Write(after)

lines := buf.Lines()
if buf.Err() == nil {
t.Error("expected a non-nil error from Err() after long line, got nil")
}
for _, l := range lines {
if l == "after" {
t.Error("lines after the oversized line should not appear when truncated")
}
}
}

func TestOutputBufferSetScannerBufferSize(t *testing.T) {
// SetScannerBufferSize allows reading lines larger than 64KB.
buf := cmd.NewOutputBuffer()
buf.SetScannerBufferSize(256 * 1024) // 256KB

longLine := bytes.Repeat([]byte("y"), 65*1024) // 65KB
longLine = append(longLine, '\n')
buf.Write(longLine)
buf.Write([]byte("after\n"))

lines := buf.Lines()
if err := buf.Err(); err != nil {
t.Errorf("expected no error with enlarged buffer, got: %v", err)
}
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if len(lines[0]) != 65*1024 {
t.Errorf("expected first line of %d bytes, got %d", 65*1024, len(lines[0]))
}
if lines[1] != "after" {
t.Errorf("expected second line 'after', got %q", lines[1])
}
}

func TestOutputBufferLineBufferSizeOption(t *testing.T) {
// Options.LineBufferSize propagates to OutputBuffer when Buffered is true.
const bufSize = 256 * 1024 // 256KB
p := cmd.NewCmdOptions(
cmd.Options{Buffered: true, LineBufferSize: bufSize},
"echo",
string(bytes.Repeat([]byte("z"), 65*1024)), // 65KB argument → one long output line
)
status := <-p.Start()
if status.Error != nil {
t.Fatalf("command error: %v", status.Error)
}
if len(status.Stdout) != 1 {
t.Fatalf("expected 1 line of stdout, got %d", len(status.Stdout))
}
if len(status.Stdout[0]) != 65*1024 {
t.Errorf("expected stdout line of %d bytes, got %d", 65*1024, len(status.Stdout[0]))
}
}

func TestCmdStatusErrorOnScannerOverflow(t *testing.T) {
// When a line exceeds the scanner buffer size, status.Error must be non-nil
// so the caller can detect the truncation without inspecting OutputBuffer directly.
p := cmd.NewCmdOptions(
cmd.Options{Buffered: true}, // default 64KB limit
"echo",
string(bytes.Repeat([]byte("x"), 65*1024)), // 65KB > 64KB
)
status := <-p.Start()
if status.Error == nil {
t.Error("expected status.Error to be non-nil on scanner overflow, got nil")
}
}

func TestOutputBufferClonePropagatesScannerBufSize(t *testing.T) {
// Clone must propagate LineBufferSize to the cloned Cmd's OutputBuffer.
const bufSize = 256 * 1024
p := cmd.NewCmdOptions(
cmd.Options{Buffered: true, LineBufferSize: bufSize},
"echo",
string(bytes.Repeat([]byte("z"), 65*1024)),
)
clone := p.Clone()
status := <-clone.Start()
if status.Error != nil {
t.Fatalf("clone command error: %v", status.Error)
}
if len(status.Stdout) != 1 {
t.Fatalf("expected 1 line from clone stdout, got %d", len(status.Stdout))
}
if len(status.Stdout[0]) != 65*1024 {
t.Errorf("expected clone stdout line of %d bytes, got %d", 65*1024, len(status.Stdout[0]))
}
}
2 changes: 1 addition & 1 deletion cmd_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"testing"
"time"

"github.com/go-cmd/cmd"
"github.com/orus-io/cmd"
"github.com/go-test/deep"
)

Expand Down
2 changes: 1 addition & 1 deletion examples/blocking-buffered/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package main
import (
"fmt"

"github.com/go-cmd/cmd"
"github.com/orus-io/cmd"
)

func main() {
Expand Down
2 changes: 1 addition & 1 deletion examples/blocking-streaming/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"fmt"
"os"

"github.com/go-cmd/cmd"
"github.com/orus-io/cmd"
)

func main() {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/go-cmd/cmd
module github.com/orus-io/cmd

go 1.20

Expand Down