diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1962a37..f262bdf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Go Test +name: Quality on: push: @@ -8,7 +8,12 @@ on: jobs: test: + name: Go ${{ matrix.go-version }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.24.4", "stable"] steps: - name: Checkout @@ -17,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "stable" + go-version: ${{ matrix.go-version }} - name: Verify module files run: | @@ -28,14 +33,42 @@ jobs: - name: Run tests run: | - go test ./... -v + go test ./... go -C docs test ./... go -C examples test ./... - - name: Run tests with coverage - run: go test -coverprofile=coverage.txt . + - name: Run static analysis + run: | + go vet ./... + go -C docs vet ./... + go -C examples vet ./... + + - name: Compile platform-specific tests + run: | + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/execx-windows.test . + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go test -c -o /tmp/execx-darwin.test . + GOOS=plan9 GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/execx-plan9.test . + GOOS=js GOARCH=wasm CGO_ENABLED=0 go test -c -o /tmp/execx-js.test . + + - name: Run race detector + run: go test -race ./... + + - name: Enforce full statement coverage + run: | + go test -coverprofile=coverage.txt . + coverage="$(go tool cover -func=coverage.txt | awk '/^total:/ { print $3 }')" + test "$coverage" = "100.0%" + + - name: Verify generated documentation + run: | + go -C docs run ./examplegen + go -C docs run ./readme + git diff --exit-code - name: Upload results to Codecov + if: matrix.go-version == 'stable' uses: codecov/codecov-action@v5 + with: + files: coverage.txt env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 02f3988..a1a6691 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Go Reference License: MIT Go Test - Go version + Go version Latest tag - Tests + Tests Go Report Card

@@ -25,8 +25,18 @@ execx is a small, explicit wrapper around `os/exec`. It keeps the `exec.Cmd` mod There is no shell interpolation. Arguments, environment, and I/O are set directly, and nothing runs until you call `Run`, `Output`, or `Start`. +The core contract is deliberately narrow: + +- a non-zero child exit is data in `Result.ExitCode`, not a Go error; +- start, context, and I/O failures are returned as errors and mirrored in `Result.Err`; +- a `Cmd` is mutable and must not be configured or executed concurrently; +- output callbacks and caller-provided writers are serialized within one pipeline; +- strings shown by `String`, `ShellEscaped`, and `ShadowPrint` are for display only and are never executed by a shell. + ## Installation +execx requires Go 1.24.4 or newer. + ```bash go get github.com/goforj/execx ``` @@ -80,8 +90,17 @@ fmt.Println(out) On Windows, use `cmd /c` or `powershell -Command` for shell built-ins. -`PipeStrict` (default) stops at the first failing stage and returns that error. -`PipeBestEffort` runs all stages, returns the last stage output, and surfaces the first error if any stage failed. +Every stage is started concurrently. `PipeStrict` (default) selects the first stage with an execution error or non-zero exit as the primary result. `PipeBestEffort` selects the last stage and surfaces the first execution error. Neither mode turns a non-zero exit into a Go error. + +Configuration is stage-local. Configure a stage before calling `Pipe`, then configure the returned stage separately: + +```go +first := execx.Command("producer").Env("ROLE=producer") +second := first.Pipe("consumer").Env("ROLE=consumer") +res, err := second.Run() +``` + +`PipeStrict`, `PipeBestEffort`, `WithPTY`, and shadow-print settings are chain-wide. ## Context & cancellation @@ -93,6 +112,8 @@ fmt.Println(res.ExitCode == 0) // #bool true ``` +Contexts are stage-local in pipelines. Replacing `WithTimeout` or `WithDeadline` keeps the context supplied through `WithContext` as the parent, including an already-canceled parent. + ## Environment & I/O control Environment is explicit and deterministic: @@ -113,6 +134,8 @@ fmt.Println(out) // #string hi ``` +`StdinBytes` copies its input. `OnStdout` and `OnStderr` receive complete lines, including a final line without a trailing newline. A failure from `StdoutWriter` or `StderrWriter` is returned as `ErrExec`; bytes received before that failure remain available in `Result`. + ## Advanced features For process control, use `Start` with the `Process` helpers: @@ -174,6 +197,7 @@ execx returns two error surfaces: 1) `err` (from `Run`, `Output`, `CombinedOutput`, `Wait`, etc) only reports execution failures: - start failures (binary not found, not executable, OS start error) - context cancellations or timeouts (`WithContext`, `WithTimeout`, `WithDeadline`) + - output capture, callback writer, and caller-provided writer failures - pipeline failures based on `PipeStrict` / `PipeBestEffort` 2) `Result.Err` mirrors `err` for convenience; it is not for exit status. @@ -182,6 +206,23 @@ Exit status is always reported via `Result.ExitCode`, even on non-zero exits. A Use `err` when you want to handle execution failures, and check `Result.ExitCode` (or `Result.OK()` / `Result.IsExitCode`) when you care about command success. +## Security and logging + +`Command(name, args...)` invokes the executable directly. It does not parse shell syntax, expand variables, glob paths, or interpret redirections. If you explicitly run a shell such as `sh -c` or `bash -c`, the command string becomes subject to that shell's rules and must be treated as untrusted input. + +`ShadowPrint` can expose arguments. Use `WithMask` for secrets. Custom formatters receive both masked `ShadowEvent.Command` and unmasked `ShadowEvent.RawCommand`; avoid logging the raw value. + +## Compatibility notes + +The v1 API and its non-zero-exit contract remain unchanged. This quality pass tightens four cases that were previously lossy or unsafe: + +- final unterminated output is now delivered to line callbacks; +- caller-provided writer and PTY copy failures now return `ErrExec`; +- `StdinBytes` no longer observes later mutations of the caller's slice; +- replacement timeouts and deadlines no longer detach from their original parent context. + +Code that intentionally depended on one of those edge behaviors should update before adopting the next release. No exported signature changed. + ## Non-goals and design principles Design principles: @@ -215,7 +256,6 @@ All public APIs are covered by runnable examples under `./examples`, and the tes | **Execution** | [CombinedOutput](#combinedoutput) · [OnExecCmd](#onexeccmd) · [Output](#output) · [OutputBytes](#outputbytes) · [OutputTrimmed](#outputtrimmed) · [Run](#run) · [Start](#start) | | **Input** | [StdinBytes](#stdinbytes) · [StdinFile](#stdinfile) · [StdinReader](#stdinreader) · [StdinString](#stdinstring) | | **OS Controls** | [CreationFlags](#creationflags) · [HideWindow](#hidewindow) · [Pdeathsig](#pdeathsig) · [Setpgid](#setpgid) · [Setsid](#setsid) | -| **Other** | [Write](#write) | | **Pipelining** | [Pipe](#pipe) · [PipeBestEffort](#pipebesteffort) · [PipeStrict](#pipestrict) · [PipelineResults](#pipelineresults) | | **Process** | [GracefulShutdown](#gracefulshutdown) · [Interrupt](#interrupt) · [KillAfter](#killafter) · [Send](#send) · [Terminate](#terminate) · [Wait](#wait) | | **Results** | [IsExitCode](#isexitcode) · [IsSignal](#issignal) · [OK](#ok) | @@ -254,7 +294,8 @@ fmt.Print(out) ### WithContext -WithContext binds the command to a context. +WithContext binds the command to a context. A nil context is normalized to +context.Background when execution begins. ```go ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -266,7 +307,8 @@ fmt.Println(res.ExitCode == 0) ### WithDeadline -WithDeadline binds the command to a deadline. +WithDeadline binds the command to a deadline. Replacing a deadline retains the +context previously supplied through WithContext as its parent. ```go res, _ := execx.Command("go", "env", "GOOS").WithDeadline(time.Now().Add(2 * time.Second)).Run() @@ -276,7 +318,8 @@ fmt.Println(res.ExitCode == 0) ### WithTimeout -WithTimeout binds the command to a timeout. +WithTimeout binds the command to a timeout. Replacing a timeout retains the +context previously supplied through WithContext as its parent. ```go res, _ := execx.Command("go", "env", "GOOS").WithTimeout(2 * time.Second).Run() @@ -558,7 +601,9 @@ fmt.Println(err.Unwrap() != nil) ### CombinedOutput -CombinedOutput executes the command and returns stdout+stderr and any error. +CombinedOutput executes the command and returns stdout and stderr in observed +chunk order plus any execution error. Exact byte interleaving between the two +operating-system streams is inherently scheduler-dependent. ```go out, err := execx.Command("go", "env", "-badflag").CombinedOutput() @@ -572,7 +617,9 @@ fmt.Println(err == nil) ### OnExecCmd -OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. +OnExecCmd registers a callback to mutate the underlying exec.Cmd before execx +attaches its stdin, output capture, and pipeline wiring. Use it for fields such +as SysProcAttr; execx owns Stdin, Stdout, and Stderr during execution. ```go _, _ = execx.Command("printf", "hi"). @@ -614,7 +661,8 @@ fmt.Println(out) ### Run -Run executes the command and returns the result and any error. +Run executes the command and returns the result and any execution error. A +non-zero exit code is represented in Result and does not itself produce an error. ```go res, _ := execx.Command("go", "env", "GOOS").Run() @@ -624,7 +672,8 @@ fmt.Println(res.ExitCode == 0) ### Start -Start executes the command asynchronously. +Start executes the command asynchronously. Startup still completes synchronously, +so a returned Process represents either a fully started pipeline or a completed error. ```go proc := execx.Command("go", "env", "GOOS").Start() @@ -637,7 +686,8 @@ fmt.Println(res.ExitCode == 0) ### StdinBytes -StdinBytes sets stdin from bytes. +StdinBytes sets stdin from a copy of bytes so later caller mutation cannot +change the command input. ```go out, _ := execx.Command("cat"). @@ -738,17 +788,13 @@ fmt.Print(out) // ok ``` -## Other - -### Write - -Write buffers output and emits completed lines to the callback. - ## Pipelining ### Pipe Pipe appends a new command to the pipeline. Pipelines run on all platforms. +Configuration called on the returned Cmd applies to the new stage; configure +the current stage before Pipe when it needs different environment, context, or I/O. ```go out, _ := execx.Command("printf", "go"). @@ -760,7 +806,8 @@ fmt.Println(out) ### PipeBestEffort -PipeBestEffort sets best-effort pipeline semantics (run all stages, surface the first error). +PipeBestEffort selects the last stage as the primary result while surfacing the +first execution error. Every stage is started concurrently. ```go res, _ := execx.Command("false"). @@ -773,7 +820,8 @@ fmt.Print(res.Stdout) ### PipeStrict -PipeStrict sets strict pipeline semantics (stop on first failure). +PipeStrict selects the first stage with an execution error or non-zero exit as +the primary result. Every stage is still started concurrently. ```go res, _ := execx.Command("false"). @@ -786,7 +834,8 @@ fmt.Println(res.ExitCode != 0) ### PipelineResults -PipelineResults executes the command and returns per-stage results and any error. +PipelineResults executes the command and returns per-stage results and the first +execution error. Non-zero exit codes remain data in their corresponding Result. ```go results, _ := execx.Command("printf", "go"). @@ -1008,7 +1057,9 @@ _, _ = execx.Command("printf", "hi").ShadowPrint(execx.WithPrefix("run")).Run() ### OnStderr -OnStderr registers a line callback for stderr. +OnStderr registers a line callback for stderr. The final unterminated line is +delivered after the process exits. Output callbacks and writers are serialized +across one pipeline. ```go _, err := execx.Command("go", "env", "-badflag"). @@ -1025,7 +1076,9 @@ fmt.Println(err == nil) ### OnStdout -OnStdout registers a line callback for stdout. +OnStdout registers a line callback for stdout. The final unterminated line is +delivered after the process exits. Output callbacks and writers are serialized +across one pipeline. ```go _, _ = execx.Command("printf", "hi\n"). @@ -1040,6 +1093,7 @@ StderrWriter sets a raw writer for stderr. When the writer is a terminal and no line callbacks or combined output are enabled, execx passes stderr through directly and does not buffer it for results. +Writer failures are returned as ErrExec after already received bytes are captured. ```go var out strings.Builder @@ -1060,6 +1114,7 @@ StdoutWriter sets a raw writer for stdout. When the writer is a terminal and no line callbacks or combined output are enabled, execx passes stdout through directly and does not buffer it for results. +Writer failures are returned as ErrExec after already received bytes are captured. ```go var out strings.Builder diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..4393e21 --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,24 @@ +package execx + +import "testing" + +var benchmarkArgs []string + +// BenchmarkCommandConstruction measures fluent argument assembly without subprocess noise. +func BenchmarkCommandConstruction(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchmarkArgs = Command("tool", "base"). + Arg("value", []string{"one", "two"}, map[string]string{"--mode": "fast"}). + Args() + } +} + +// BenchmarkShellEscaped measures the logging representation used by shadow printing. +func BenchmarkShellEscaped(b *testing.B) { + cmd := Command("tool", "hello world", "it's", "$TOKEN") + b.ReportAllocs() + for b.Loop() { + benchmarkArgs = []string{cmd.ShellEscaped()} + } +} diff --git a/creationflags_windows.go b/creationflags_windows.go index e255a80..8fe6f5a 100644 --- a/creationflags_windows.go +++ b/creationflags_windows.go @@ -2,13 +2,11 @@ package execx -import "syscall" - const ( // CreateNewProcessGroup starts the process in a new process group. - CreateNewProcessGroup = syscall.CREATE_NEW_PROCESS_GROUP + CreateNewProcessGroup = 0x00000200 // CreateNewConsole creates a new console for the process. - CreateNewConsole = syscall.CREATE_NEW_CONSOLE + CreateNewConsole = 0x00000010 // CreateNoWindow prevents console windows from being created. - CreateNoWindow = syscall.CREATE_NO_WINDOW + CreateNoWindow = 0x08000000 ) diff --git a/decode.go b/decode.go index 36c9115..696863d 100644 --- a/decode.go +++ b/decode.go @@ -12,6 +12,7 @@ import ( // Decoder decodes serialized data into a destination value. type Decoder interface { + // Decode parses data into dst. Decode(data []byte, dst any) error } @@ -31,6 +32,7 @@ type decodeConfig struct { trim bool } +// defaultDecodeConfig keeps stdout and unmodified bytes as the least surprising decode path. func defaultDecodeConfig() decodeConfig { return decodeConfig{source: decodeStdout} } @@ -235,6 +237,7 @@ func (c *Cmd) DecodeWith(dst any, decoder Decoder) error { return decodeInto(c, dst, decoder, defaultDecodeConfig()) } +// decodeInto validates reflection-sensitive inputs before starting the subprocess. func decodeInto(c *Cmd, dst any, decoder Decoder, cfg decodeConfig) error { if c == nil { return errors.New("command is nil") @@ -262,6 +265,7 @@ func decodeInto(c *Cmd, dst any, decoder Decoder, cfg decodeConfig) error { return nil } +// decodeSource executes exactly once through the output path selected by the chain. func (c *Cmd) decodeSource(source decodeSource) ([]byte, error) { switch source { case decodeCombined: @@ -283,6 +287,7 @@ func (c *Cmd) decodeSource(source decodeSource) ([]byte, error) { } } +// decodeSourceName adds stable context to decoder failures without obscuring their cause. func decodeSourceName(source decodeSource) string { switch source { case decodeCombined: diff --git a/decode_test.go b/decode_test.go index 43f353c..21f77d2 100644 --- a/decode_test.go +++ b/decode_test.go @@ -12,6 +12,7 @@ type testPayload struct { Name string `json:"name"` } +// TestDecodeYAMLInto ensures YAML command output can populate a caller-owned destination. func TestDecodeYAMLInto(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("decode yaml test uses printf") @@ -30,6 +31,7 @@ func TestDecodeYAMLInto(t *testing.T) { } } +// TestDecodeFromStdout ensures the explicit stdout source feeds the selected decoder. func TestDecodeFromStdout(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("decode stdout test uses printf") @@ -46,6 +48,7 @@ func TestDecodeFromStdout(t *testing.T) { } } +// TestDecodeNilCmd ensures fluent decoding rejects a nil command instead of panicking. func TestDecodeNilCmd(t *testing.T) { var out testPayload err := (*Cmd)(nil). @@ -56,6 +59,7 @@ func TestDecodeNilCmd(t *testing.T) { } } +// TestDecodeWithJSON ensures custom decoder functions use the same execution path as built-in decoders. func TestDecodeWithJSON(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("output into test uses printf") @@ -71,6 +75,7 @@ func TestDecodeWithJSON(t *testing.T) { } } +// TestDecodeTrim ensures callers can discard surrounding whitespace before strict decoding. func TestDecodeTrim(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("decode trim test uses printf") @@ -88,6 +93,7 @@ func TestDecodeTrim(t *testing.T) { } } +// TestDecodeCombined ensures combined output can be selected as the decoder input. func TestDecodeCombined(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("combined output test uses sh") @@ -105,6 +111,7 @@ func TestDecodeCombined(t *testing.T) { } } +// TestDecodeCombinedStartError ensures process startup failures survive the combined-output wrapper. func TestDecodeCombinedStartError(t *testing.T) { var out testPayload err := Command("execx-does-not-exist"). @@ -116,6 +123,7 @@ func TestDecodeCombinedStartError(t *testing.T) { } } +// TestDecodeCombinedDecodeError ensures malformed combined output identifies its source in the error. func TestDecodeCombinedDecodeError(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("combined output test uses sh") @@ -130,6 +138,7 @@ func TestDecodeCombinedDecodeError(t *testing.T) { } } +// TestDecodeStderr ensures structured data written only to stderr remains decodable. func TestDecodeStderr(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("stderr output test uses sh") @@ -147,6 +156,7 @@ func TestDecodeStderr(t *testing.T) { } } +// TestDecodeStderrStartError ensures process startup failures survive the stderr wrapper. func TestDecodeStderrStartError(t *testing.T) { var out testPayload err := Command("execx-does-not-exist"). @@ -158,6 +168,7 @@ func TestDecodeStderrStartError(t *testing.T) { } } +// TestDecodeStderrDecodeError ensures malformed stderr output identifies its source in the error. func TestDecodeStderrDecodeError(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("stderr output test uses sh") @@ -172,6 +183,7 @@ func TestDecodeStderrDecodeError(t *testing.T) { } } +// TestDecodeWithErrors ensures invalid destinations and missing decoders fail before execution. func TestDecodeWithErrors(t *testing.T) { err := Command("printf", `{"name":"gopher"}`). DecodeWith(nil, DecoderFunc(json.Unmarshal)) @@ -193,6 +205,7 @@ func TestDecodeWithErrors(t *testing.T) { } } +// TestDecodeErrorWrap ensures decoder failures retain useful underlying error detail. func TestDecodeErrorWrap(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("decode error test uses printf") diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6f2d590 --- /dev/null +++ b/doc.go @@ -0,0 +1,13 @@ +// Package execx provides explicit, fluent subprocess execution without shell +// parsing or interpolation. +// +// A Cmd is a mutable configuration value and must not be changed or executed +// concurrently. Each execution creates fresh os/exec commands, captures stdout +// and stderr, and reports process status in Result. A non-zero child exit is +// represented by Result.ExitCode rather than an error; errors are reserved for +// failures to start, cancellation, and I/O failures. +// +// Pipelines start every stage concurrently. Stage configuration methods apply +// to the stage on which they are called, while PipeStrict, PipeBestEffort, +// WithPTY, and shadow-print settings apply to the full chain. +package execx diff --git a/docs/examplegen/main.go b/docs/examplegen/main.go index dc131cc..fb37236 100644 --- a/docs/examplegen/main.go +++ b/docs/examplegen/main.go @@ -145,6 +145,7 @@ func modulePath(root string) (string, error) { // ------------------------------------------------------------ // +// FuncDoc captures the metadata needed to render one documented function. type FuncDoc struct { Name string Group string @@ -152,6 +153,7 @@ type FuncDoc struct { Examples []Example } +// Example captures an executable snippet and its source location. type Example struct { FuncName string File string @@ -190,7 +192,7 @@ func extractFuncDocs( } name := fn.Name.Name - if !ast.IsExported(name) { + if !ast.IsExported(name) || !hasExportedReceiver(fn) { continue } @@ -205,6 +207,19 @@ func extractFuncDocs( return out } +// hasExportedReceiver excludes methods whose unexported receiver makes them package-internal. +func hasExportedReceiver(fn *ast.FuncDecl) bool { + if fn.Recv == nil { + return true + } + typeExpr := fn.Recv.List[0].Type + if pointer, ok := typeExpr.(*ast.StarExpr); ok { + typeExpr = pointer.X + } + identifier, ok := typeExpr.(*ast.Ident) + return ok && ast.IsExported(identifier.Name) +} + // extractGroup keeps the generated index aligned with the source-level grouping annotation. func extractGroup(group *ast.CommentGroup) string { lines := docLines(group) @@ -473,6 +488,7 @@ func writeMain(base string, fd *FuncDoc, importPath string) error { buf.WriteString(")\n\n") } + buf.WriteString("// main keeps this documented example executable so API drift fails during compilation.\n") buf.WriteString("func main() {\n") if fd.Description != "" { diff --git a/docs/examplegen/main_test.go b/docs/examplegen/main_test.go new file mode 100644 index 0000000..eb29322 --- /dev/null +++ b/docs/examplegen/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// TestHasExportedReceiver verifies that example generation follows the public API boundary. +func TestHasExportedReceiver(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "surface.go", ` +package surface +type Public struct{} +type private struct{} +func Top() {} +func (*Public) Method() {} +func (*private) Hidden() {} +`, 0) + if err != nil { + t.Fatalf("parse declarations: %v", err) + } + + got := map[string]bool{} + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok { + got[fn.Name.Name] = hasExportedReceiver(fn) + } + } + if !got["Top"] || !got["Method"] || got["Hidden"] { + t.Fatalf("unexpected receiver visibility: %v", got) + } +} diff --git a/docs/readme/main.go b/docs/readme/main.go index 7947b35..50161f0 100644 --- a/docs/readme/main.go +++ b/docs/readme/main.go @@ -76,6 +76,7 @@ func run() error { // ------------------------------------------------------------ // +// FuncDoc captures the metadata needed to render one documented function. type FuncDoc struct { Name string Group string @@ -85,6 +86,7 @@ type FuncDoc struct { Examples []Example } +// Example captures an executable snippet and its source location. type Example struct { Label string Code string @@ -146,7 +148,7 @@ func parseFuncs(root string) ([]*FuncDoc, error) { continue } - if !ast.IsExported(fn.Name.Name) { + if !ast.IsExported(fn.Name.Name) || !hasExportedReceiver(fn) { continue } @@ -178,6 +180,19 @@ func parseFuncs(root string) ([]*FuncDoc, error) { return out, nil } +// hasExportedReceiver excludes methods whose unexported receiver makes them package-internal. +func hasExportedReceiver(fn *ast.FuncDecl) bool { + if fn.Recv == nil { + return true + } + typeExpr := fn.Recv.List[0].Type + if pointer, ok := typeExpr.(*ast.StarExpr); ok { + typeExpr = pointer.X + } + identifier, ok := typeExpr.(*ast.Ident) + return ok && ast.IsExported(identifier.Name) +} + // extractGroup keeps the generated index aligned with the source-level grouping annotation. func extractGroup(group *ast.CommentGroup) string { for _, c := range group.List { diff --git a/docs/readme/main_test.go b/docs/readme/main_test.go new file mode 100644 index 0000000..61df8ad --- /dev/null +++ b/docs/readme/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// TestHasExportedReceiver verifies that generated API docs exclude internal helper methods. +func TestHasExportedReceiver(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "surface.go", ` +package surface +type Public struct{} +type private struct{} +func Top() {} +func (*Public) Method() {} +func (*private) Hidden() {} +`, 0) + if err != nil { + t.Fatalf("parse declarations: %v", err) + } + + got := map[string]bool{} + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok { + got[fn.Name.Name] = hasExportedReceiver(fn) + } + } + if !got["Top"] || !got["Method"] || got["Hidden"] { + t.Fatalf("unexpected receiver visibility: %v", got) + } +} diff --git a/err_exec.go b/err_exec.go index 5300e9e..e9c06fd 100644 --- a/err_exec.go +++ b/err_exec.go @@ -4,10 +4,14 @@ import "os" // ErrExec reports a failure to start or an explicit execution failure. type ErrExec struct { - Err error + // Err contains the underlying start or I/O failure. + Err error + // ExitCode contains the child status when a process reached execution. ExitCode int - Signal os.Signal - Stderr string + // Signal contains the terminating signal on platforms that expose one. + Signal os.Signal + // Stderr contains captured diagnostic output available when the failure occurred. + Stderr string } // Error returns the wrapped error message when available. diff --git a/examples/arg/main.go b/examples/arg/main.go index 9cfaf93..f9582ee 100644 --- a/examples/arg/main.go +++ b/examples/arg/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Arg appends arguments to the command. diff --git a/examples/args/main.go b/examples/args/main.go index 52dfb88..9fe5854 100644 --- a/examples/args/main.go +++ b/examples/args/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Args returns the argv slice used for execution. diff --git a/examples/combinedoutput/main.go b/examples/combinedoutput/main.go index 571f8a8..0667f7d 100644 --- a/examples/combinedoutput/main.go +++ b/examples/combinedoutput/main.go @@ -5,8 +5,11 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // CombinedOutput executes the command and returns stdout+stderr and any error. + // CombinedOutput executes the command and returns stdout and stderr in observed + // chunk order plus any execution error. Exact byte interleaving between the two + // operating-system streams is inherently scheduler-dependent. // Example: combined output out, err := execx.Command("go", "env", "-badflag").CombinedOutput() diff --git a/examples/command/main.go b/examples/command/main.go index 9e9c0f7..7d83fb2 100644 --- a/examples/command/main.go +++ b/examples/command/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Command constructs a new command without executing it. diff --git a/examples/creationflags/main.go b/examples/creationflags/main.go index f90ec88..5cab12f 100644 --- a/examples/creationflags/main.go +++ b/examples/creationflags/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. diff --git a/examples/decode/main.go b/examples/decode/main.go index 53a23b4..c4ffc53 100644 --- a/examples/decode/main.go +++ b/examples/decode/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps the decode example executable so decoder API drift fails during compilation. func main() { // Decode configures a custom decoder for this command. diff --git a/examples/decodejson/main.go b/examples/decodejson/main.go index 737f42d..7fb98b9 100644 --- a/examples/decodejson/main.go +++ b/examples/decodejson/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // DecodeJSON configures JSON decoding for this command. // Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. diff --git a/examples/decodewith/main.go b/examples/decodewith/main.go index a402968..7c14345 100644 --- a/examples/decodewith/main.go +++ b/examples/decodewith/main.go @@ -6,6 +6,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // DecodeWith executes the command and decodes stdout into dst. diff --git a/examples/decodeyaml/main.go b/examples/decodeyaml/main.go index 65b71ea..fc54ead 100644 --- a/examples/decodeyaml/main.go +++ b/examples/decodeyaml/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // DecodeYAML configures YAML decoding for this command. // Decoding reads from stdout by default; use FromStdout, FromStderr, or FromCombined to select a source. diff --git a/examples/dir/main.go b/examples/dir/main.go index e73ad26..9ae9f04 100644 --- a/examples/dir/main.go +++ b/examples/dir/main.go @@ -6,6 +6,7 @@ import ( "os" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Dir sets the working directory. diff --git a/examples/env/main.go b/examples/env/main.go index c113638..acb70ab 100644 --- a/examples/env/main.go +++ b/examples/env/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Env adds environment variables to the command. diff --git a/examples/envappend/main.go b/examples/envappend/main.go index 4f2faed..674373f 100644 --- a/examples/envappend/main.go +++ b/examples/envappend/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // EnvAppend merges variables into the inherited environment. diff --git a/examples/envinherit/main.go b/examples/envinherit/main.go index d3fdc03..5332a22 100644 --- a/examples/envinherit/main.go +++ b/examples/envinherit/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // EnvInherit restores default environment inheritance. diff --git a/examples/envlist/main.go b/examples/envlist/main.go index 7258f28..b6ac021 100644 --- a/examples/envlist/main.go +++ b/examples/envlist/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // EnvList returns the environment list for execution. diff --git a/examples/envonly/main.go b/examples/envonly/main.go index ea3b070..67f21ce 100644 --- a/examples/envonly/main.go +++ b/examples/envonly/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // EnvOnly ignores the parent environment. diff --git a/examples/error/main.go b/examples/error/main.go index 6d5a420..7054722 100644 --- a/examples/error/main.go +++ b/examples/error/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Error returns the wrapped error message when available. diff --git a/examples/fromcombined/main.go b/examples/fromcombined/main.go index accdc94..b2df283 100644 --- a/examples/fromcombined/main.go +++ b/examples/fromcombined/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // FromCombined decodes from combined stdout+stderr. diff --git a/examples/fromstderr/main.go b/examples/fromstderr/main.go index a2a35b4..c66e06f 100644 --- a/examples/fromstderr/main.go +++ b/examples/fromstderr/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // FromStderr decodes from stderr. diff --git a/examples/fromstdout/main.go b/examples/fromstdout/main.go index 61d7310..9e91731 100644 --- a/examples/fromstdout/main.go +++ b/examples/fromstdout/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // FromStdout decodes from stdout (default). diff --git a/examples/go.mod b/examples/go.mod index 46bf18c..0c47b2e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -5,8 +5,8 @@ go 1.24.4 require github.com/goforj/execx v1.1.0 require ( - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect - golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index fc1e6bc..0cc80d5 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,7 +1,7 @@ -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/examples/gracefulshutdown/main.go b/examples/gracefulshutdown/main.go index 8c6527d..fecce9c 100644 --- a/examples/gracefulshutdown/main.go +++ b/examples/gracefulshutdown/main.go @@ -7,6 +7,7 @@ import ( "time" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // GracefulShutdown sends a signal and escalates to kill after the timeout. diff --git a/examples/hidewindow/main.go b/examples/hidewindow/main.go index 62c3f0b..096a568 100644 --- a/examples/hidewindow/main.go +++ b/examples/hidewindow/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. diff --git a/examples/interrupt/main.go b/examples/interrupt/main.go index a12b134..b625aa5 100644 --- a/examples/interrupt/main.go +++ b/examples/interrupt/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Interrupt sends an interrupt signal to the process. diff --git a/examples/into/main.go b/examples/into/main.go index c0a7de8..6adba21 100644 --- a/examples/into/main.go +++ b/examples/into/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Into executes the command and decodes into dst. diff --git a/examples/isexitcode/main.go b/examples/isexitcode/main.go index c6799d9..83ae587 100644 --- a/examples/isexitcode/main.go +++ b/examples/isexitcode/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsExitCode reports whether the exit code matches. diff --git a/examples/issignal/main.go b/examples/issignal/main.go index 8d5862d..b884414 100644 --- a/examples/issignal/main.go +++ b/examples/issignal/main.go @@ -6,6 +6,7 @@ import ( "os" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // IsSignal reports whether the command terminated due to a signal. diff --git a/examples/killafter/main.go b/examples/killafter/main.go index 1a43131..b18c90f 100644 --- a/examples/killafter/main.go +++ b/examples/killafter/main.go @@ -6,6 +6,7 @@ import ( "time" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // KillAfter terminates the process after the given duration. diff --git a/examples/kitchensink/main.go b/examples/kitchensink/main.go index 0bcc229..d81af51 100644 --- a/examples/kitchensink/main.go +++ b/examples/kitchensink/main.go @@ -8,6 +8,7 @@ import ( "time" ) +// main keeps this combined example executable so cross-feature API drift fails during compilation. func main() { // Run executes the command and returns the result and any error. diff --git a/examples/ok/main.go b/examples/ok/main.go index 5c10375..9ff0acf 100644 --- a/examples/ok/main.go +++ b/examples/ok/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // OK reports whether the command exited cleanly without errors. diff --git a/examples/onexeccmd/main.go b/examples/onexeccmd/main.go index bc862d8..6f111b4 100644 --- a/examples/onexeccmd/main.go +++ b/examples/onexeccmd/main.go @@ -6,8 +6,11 @@ import ( "syscall" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. + // OnExecCmd registers a callback to mutate the underlying exec.Cmd before execx + // attaches its stdin, output capture, and pipeline wiring. Use it for fields such + // as SysProcAttr; execx owns Stdin, Stdout, and Stderr during execution. // Example: exec cmd _, _ = execx.Command("printf", "hi"). diff --git a/examples/onstderr/main.go b/examples/onstderr/main.go index bc6fa9b..0ae5988 100644 --- a/examples/onstderr/main.go +++ b/examples/onstderr/main.go @@ -5,8 +5,11 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // OnStderr registers a line callback for stderr. + // OnStderr registers a line callback for stderr. The final unterminated line is + // delivered after the process exits. Output callbacks and writers are serialized + // across one pipeline. // Example: stderr lines _, err := execx.Command("go", "env", "-badflag"). diff --git a/examples/onstdout/main.go b/examples/onstdout/main.go index d5ee378..e9eaa8e 100644 --- a/examples/onstdout/main.go +++ b/examples/onstdout/main.go @@ -5,8 +5,11 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // OnStdout registers a line callback for stdout. + // OnStdout registers a line callback for stdout. The final unterminated line is + // delivered after the process exits. Output callbacks and writers are serialized + // across one pipeline. // Example: stdout lines _, _ = execx.Command("printf", "hi\n"). diff --git a/examples/output/main.go b/examples/output/main.go index 94bc9eb..584bdd3 100644 --- a/examples/output/main.go +++ b/examples/output/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Output executes the command and returns stdout and any error. diff --git a/examples/outputbytes/main.go b/examples/outputbytes/main.go index 431cb29..a00c316 100644 --- a/examples/outputbytes/main.go +++ b/examples/outputbytes/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // OutputBytes executes the command and returns stdout bytes and any error. diff --git a/examples/outputtrimmed/main.go b/examples/outputtrimmed/main.go index dc55abe..c0cf4c7 100644 --- a/examples/outputtrimmed/main.go +++ b/examples/outputtrimmed/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // OutputTrimmed executes the command and returns trimmed stdout and any error. diff --git a/examples/pdeathsig/main.go b/examples/pdeathsig/main.go index b16e3ce..8eeee1c 100644 --- a/examples/pdeathsig/main.go +++ b/examples/pdeathsig/main.go @@ -6,6 +6,7 @@ import ( "syscall" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Pdeathsig is a no-op on non-Linux platforms; on Linux it signals the child when the parent exits. diff --git a/examples/pipe/main.go b/examples/pipe/main.go index fde108a..2dd4653 100644 --- a/examples/pipe/main.go +++ b/examples/pipe/main.go @@ -5,8 +5,11 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Pipe appends a new command to the pipeline. Pipelines run on all platforms. + // Configuration called on the returned Cmd applies to the new stage; configure + // the current stage before Pipe when it needs different environment, context, or I/O. // Example: pipe out, _ := execx.Command("printf", "go"). diff --git a/examples/pipebesteffort/main.go b/examples/pipebesteffort/main.go index 1d7f84a..f402d4b 100644 --- a/examples/pipebesteffort/main.go +++ b/examples/pipebesteffort/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // PipeBestEffort sets best-effort pipeline semantics (run all stages, surface the first error). + // PipeBestEffort selects the last stage as the primary result while surfacing the + // first execution error. Every stage is started concurrently. // Example: best effort res, _ := execx.Command("false"). diff --git a/examples/pipelineresults/main.go b/examples/pipelineresults/main.go index 7571287..7a829e9 100644 --- a/examples/pipelineresults/main.go +++ b/examples/pipelineresults/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // PipelineResults executes the command and returns per-stage results and any error. + // PipelineResults executes the command and returns per-stage results and the first + // execution error. Non-zero exit codes remain data in their corresponding Result. // Example: pipeline results results, _ := execx.Command("printf", "go"). diff --git a/examples/pipestrict/main.go b/examples/pipestrict/main.go index 93e31ca..99fd4df 100644 --- a/examples/pipestrict/main.go +++ b/examples/pipestrict/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // PipeStrict sets strict pipeline semantics (stop on first failure). + // PipeStrict selects the first stage with an execution error or non-zero exit as + // the primary result. Every stage is still started concurrently. // Example: strict res, _ := execx.Command("false"). diff --git a/examples/run/main.go b/examples/run/main.go index 686403a..3f98970 100644 --- a/examples/run/main.go +++ b/examples/run/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // Run executes the command and returns the result and any error. + // Run executes the command and returns the result and any execution error. A + // non-zero exit code is represented in Result and does not itself produce an error. // Example: run res, _ := execx.Command("go", "env", "GOOS").Run() diff --git a/examples/send/main.go b/examples/send/main.go index 944f083..6a672d9 100644 --- a/examples/send/main.go +++ b/examples/send/main.go @@ -6,6 +6,7 @@ import ( "os" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Send sends a signal to the process. diff --git a/examples/setpgid/main.go b/examples/setpgid/main.go index 28bca6f..10cd78b 100644 --- a/examples/setpgid/main.go +++ b/examples/setpgid/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Setpgid places the child in a new process group for group signals. diff --git a/examples/setsid/main.go b/examples/setsid/main.go index ee787e8..f434f07 100644 --- a/examples/setsid/main.go +++ b/examples/setsid/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Setsid starts the child in a new session, detaching it from the terminal. diff --git a/examples/shadowoff/main.go b/examples/shadowoff/main.go index eadfac0..c88d469 100644 --- a/examples/shadowoff/main.go +++ b/examples/shadowoff/main.go @@ -2,6 +2,7 @@ package main import "github.com/goforj/execx" +// main keeps this documented example executable so API drift fails during compilation. func main() { // ShadowOff disables shadow printing for this command chain, preserving configuration. diff --git a/examples/shadowon/main.go b/examples/shadowon/main.go index a7ef634..98af5c9 100644 --- a/examples/shadowon/main.go +++ b/examples/shadowon/main.go @@ -2,6 +2,7 @@ package main import "github.com/goforj/execx" +// main keeps this documented example executable so API drift fails during compilation. func main() { // ShadowOn enables shadow printing using the previously configured options. diff --git a/examples/shadowprint/main.go b/examples/shadowprint/main.go index 9d41e38..6545d91 100644 --- a/examples/shadowprint/main.go +++ b/examples/shadowprint/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // ShadowPrint configures shadow printing for this command chain. diff --git a/examples/shellescaped/main.go b/examples/shellescaped/main.go index 8b143b6..b553adf 100644 --- a/examples/shellescaped/main.go +++ b/examples/shellescaped/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // ShellEscaped returns a shell-escaped string for logging only. diff --git a/examples/start/main.go b/examples/start/main.go index ee88e2d..f9c5091 100644 --- a/examples/start/main.go +++ b/examples/start/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // Start executes the command asynchronously. + // Start executes the command asynchronously. Startup still completes synchronously, + // so a returned Process represents either a fully started pipeline or a completed error. // Example: start proc := execx.Command("go", "env", "GOOS").Start() diff --git a/examples/stderrwriter/main.go b/examples/stderrwriter/main.go index 02ad74d..595a485 100644 --- a/examples/stderrwriter/main.go +++ b/examples/stderrwriter/main.go @@ -6,11 +6,13 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // StderrWriter sets a raw writer for stderr. // // When the writer is a terminal and no line callbacks or combined output are enabled, // execx passes stderr through directly and does not buffer it for results. + // Writer failures are returned as ErrExec after already received bytes are captured. // Example: stderr writer var out strings.Builder diff --git a/examples/stdinbytes/main.go b/examples/stdinbytes/main.go index 6baa0be..79de785 100644 --- a/examples/stdinbytes/main.go +++ b/examples/stdinbytes/main.go @@ -5,8 +5,10 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // StdinBytes sets stdin from bytes. + // StdinBytes sets stdin from a copy of bytes so later caller mutation cannot + // change the command input. // Example: stdin bytes out, _ := execx.Command("cat"). diff --git a/examples/stdinfile/main.go b/examples/stdinfile/main.go index 149977e..7526bb7 100644 --- a/examples/stdinfile/main.go +++ b/examples/stdinfile/main.go @@ -6,6 +6,7 @@ import ( "os" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // StdinFile sets stdin from a file. diff --git a/examples/stdinreader/main.go b/examples/stdinreader/main.go index d7e1867..9d8dd8c 100644 --- a/examples/stdinreader/main.go +++ b/examples/stdinreader/main.go @@ -6,6 +6,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // StdinReader sets stdin from an io.Reader. diff --git a/examples/stdinstring/main.go b/examples/stdinstring/main.go index 3da9327..bf352e8 100644 --- a/examples/stdinstring/main.go +++ b/examples/stdinstring/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // StdinString sets stdin from a string. diff --git a/examples/stdoutwriter/main.go b/examples/stdoutwriter/main.go index f55805e..47d5922 100644 --- a/examples/stdoutwriter/main.go +++ b/examples/stdoutwriter/main.go @@ -6,11 +6,13 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // StdoutWriter sets a raw writer for stdout. // // When the writer is a terminal and no line callbacks or combined output are enabled, // execx passes stdout through directly and does not buffer it for results. + // Writer failures are returned as ErrExec after already received bytes are captured. // Example: stdout writer var out strings.Builder diff --git a/examples/string/main.go b/examples/string/main.go index 882b404..fd7e86d 100644 --- a/examples/string/main.go +++ b/examples/string/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // String returns a human-readable representation of the command. diff --git a/examples/terminate/main.go b/examples/terminate/main.go index 0cfa089..ac396e1 100644 --- a/examples/terminate/main.go +++ b/examples/terminate/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Terminate kills the process immediately. diff --git a/examples/trim/main.go b/examples/trim/main.go index e68aba9..7aef528 100644 --- a/examples/trim/main.go +++ b/examples/trim/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Trim trims whitespace before decoding. diff --git a/examples/unwrap/main.go b/examples/unwrap/main.go index 6e1eb63..5e002b2 100644 --- a/examples/unwrap/main.go +++ b/examples/unwrap/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Unwrap exposes the underlying error. diff --git a/examples/wait/main.go b/examples/wait/main.go index c0d341e..f51dfea 100644 --- a/examples/wait/main.go +++ b/examples/wait/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // Wait waits for the command to complete and returns the result and any error. diff --git a/examples/withcontext/main.go b/examples/withcontext/main.go index 8878b17..b63b943 100644 --- a/examples/withcontext/main.go +++ b/examples/withcontext/main.go @@ -7,8 +7,10 @@ import ( "time" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // WithContext binds the command to a context. + // WithContext binds the command to a context. A nil context is normalized to + // context.Background when execution begins. // Example: with context ctx, cancel := context.WithTimeout(context.Background(), time.Second) diff --git a/examples/withdeadline/main.go b/examples/withdeadline/main.go index 346dddc..d4ceb22 100644 --- a/examples/withdeadline/main.go +++ b/examples/withdeadline/main.go @@ -6,8 +6,10 @@ import ( "time" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // WithDeadline binds the command to a deadline. + // WithDeadline binds the command to a deadline. Replacing a deadline retains the + // context previously supplied through WithContext as its parent. // Example: with deadline res, _ := execx.Command("go", "env", "GOOS").WithDeadline(time.Now().Add(2 * time.Second)).Run() diff --git a/examples/withformatter/main.go b/examples/withformatter/main.go index f0f8f81..c4da15c 100644 --- a/examples/withformatter/main.go +++ b/examples/withformatter/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // WithFormatter sets a formatter for ShadowPrint output. diff --git a/examples/withmask/main.go b/examples/withmask/main.go index 9fd1f62..ea7daf0 100644 --- a/examples/withmask/main.go +++ b/examples/withmask/main.go @@ -5,6 +5,7 @@ import ( "strings" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // WithMask applies a masker to the shadow-printed command string. diff --git a/examples/withprefix/main.go b/examples/withprefix/main.go index cc4f669..20b7bbc 100644 --- a/examples/withprefix/main.go +++ b/examples/withprefix/main.go @@ -2,6 +2,7 @@ package main import "github.com/goforj/execx" +// main keeps this documented example executable so API drift fails during compilation. func main() { // WithPrefix sets the shadow print prefix. diff --git a/examples/withpty/main.go b/examples/withpty/main.go index f0df948..3222aad 100644 --- a/examples/withpty/main.go +++ b/examples/withpty/main.go @@ -5,6 +5,7 @@ import ( "github.com/goforj/execx" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { // WithPTY attaches stdout/stderr to a pseudo-terminal. // diff --git a/examples/withtimeout/main.go b/examples/withtimeout/main.go index 996bf6e..ef9a3b5 100644 --- a/examples/withtimeout/main.go +++ b/examples/withtimeout/main.go @@ -6,8 +6,10 @@ import ( "time" ) +// main keeps this documented example executable so API drift fails during compilation. func main() { - // WithTimeout binds the command to a timeout. + // WithTimeout binds the command to a timeout. Replacing a timeout retains the + // context previously supplied through WithContext as its parent. // Example: with timeout res, _ := execx.Command("go", "env", "GOOS").WithTimeout(2 * time.Second).Run() diff --git a/execx.go b/execx.go index e2a3642..184a670 100644 --- a/execx.go +++ b/execx.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "reflect" "regexp" "sort" "strconv" @@ -54,16 +55,18 @@ func Command(name string, args ...string) *Cmd { return cmd } -// Cmd represents a single command invocation or a pipeline stage. +// Cmd represents a single command invocation or a pipeline stage. Cmd is mutable +// and must not be configured or executed concurrently. type Cmd struct { name string args []string - env map[string]string - envMode envMode - ctx context.Context - cancel context.CancelFunc - dir string + env map[string]string + envMode envMode + ctx context.Context + ctxParent context.Context + cancel context.CancelFunc + dir string stdin io.Reader @@ -215,7 +218,8 @@ func (c *Cmd) Dir(path string) *Cmd { return c } -// WithContext binds the command to a context. +// WithContext binds the command to a context. A nil context is normalized to +// context.Background when execution begins. // @group Context // // Example: with context @@ -231,10 +235,12 @@ func (c *Cmd) WithContext(ctx context.Context) *Cmd { c.cancel = nil } c.ctx = ctx + c.ctxParent = nil return c } -// WithTimeout binds the command to a timeout. +// WithTimeout binds the command to a timeout. Replacing a timeout retains the +// context previously supplied through WithContext as its parent. // @group Context // // Example: with timeout @@ -243,20 +249,18 @@ func (c *Cmd) WithContext(ctx context.Context) *Cmd { // fmt.Println(res.ExitCode == 0) // // #bool true func (c *Cmd) WithTimeout(d time.Duration) *Cmd { - parent := c.ctx + parent := c.baseContext() if c.cancel != nil { c.cancel() c.cancel = nil - parent = nil - } - if parent == nil || parent.Err() != nil { - parent = context.Background() } + c.ctxParent = parent c.ctx, c.cancel = context.WithTimeout(parent, d) return c } -// WithDeadline binds the command to a deadline. +// WithDeadline binds the command to a deadline. Replacing a deadline retains the +// context previously supplied through WithContext as its parent. // @group Context // // Example: with deadline @@ -265,15 +269,12 @@ func (c *Cmd) WithTimeout(d time.Duration) *Cmd { // fmt.Println(res.ExitCode == 0) // // #bool true func (c *Cmd) WithDeadline(t time.Time) *Cmd { - parent := c.ctx + parent := c.baseContext() if c.cancel != nil { c.cancel() c.cancel = nil - parent = nil - } - if parent == nil || parent.Err() != nil { - parent = context.Background() } + c.ctxParent = parent c.ctx, c.cancel = context.WithDeadline(parent, t) return c } @@ -293,7 +294,8 @@ func (c *Cmd) StdinString(input string) *Cmd { return c } -// StdinBytes sets stdin from bytes. +// StdinBytes sets stdin from a copy of bytes so later caller mutation cannot +// change the command input. // @group Input // // Example: stdin bytes @@ -304,7 +306,7 @@ func (c *Cmd) StdinString(input string) *Cmd { // fmt.Println(out) // // #string hi func (c *Cmd) StdinBytes(input []byte) *Cmd { - c.stdin = bytes.NewReader(input) + c.stdin = bytes.NewReader(append([]byte(nil), input...)) return c } @@ -341,7 +343,9 @@ func (c *Cmd) StdinFile(file *os.File) *Cmd { return c } -// OnStdout registers a line callback for stdout. +// OnStdout registers a line callback for stdout. The final unterminated line is +// delivered after the process exits. Output callbacks and writers are serialized +// across one pipeline. // @group Streaming // // Example: stdout lines @@ -355,7 +359,9 @@ func (c *Cmd) OnStdout(fn func(string)) *Cmd { return c } -// OnStderr registers a line callback for stderr. +// OnStderr registers a line callback for stderr. The final unterminated line is +// delivered after the process exits. Output callbacks and writers are serialized +// across one pipeline. // @group Streaming // // Example: stderr lines @@ -380,6 +386,7 @@ func (c *Cmd) OnStderr(fn func(string)) *Cmd { // // When the writer is a terminal and no line callbacks or combined output are enabled, // execx passes stdout through directly and does not buffer it for results. +// Writer failures are returned as ErrExec after already received bytes are captured. // // Example: stdout writer // @@ -399,6 +406,7 @@ func (c *Cmd) StdoutWriter(w io.Writer) *Cmd { // // When the writer is a terminal and no line callbacks or combined output are enabled, // execx passes stderr through directly and does not buffer it for results. +// Writer failures are returned as ErrExec after already received bytes are captured. // // Example: stderr writer // @@ -436,7 +444,9 @@ func (c *Cmd) WithPTY() *Cmd { return c } -// OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. +// OnExecCmd registers a callback to mutate the underlying exec.Cmd before execx +// attaches its stdin, output capture, and pipeline wiring. Use it for fields such +// as SysProcAttr; execx owns Stdin, Stdout, and Stderr during execution. // @group Execution // // Example: exec cmd @@ -452,6 +462,8 @@ func (c *Cmd) OnExecCmd(fn func(*exec.Cmd)) *Cmd { } // Pipe appends a new command to the pipeline. Pipelines run on all platforms. +// Configuration called on the returned Cmd applies to the new stage; configure +// the current stage before Pipe when it needs different environment, context, or I/O. // @group Pipelining // // Example: pipe @@ -478,7 +490,8 @@ func (c *Cmd) Pipe(name string, args ...string) *Cmd { return next } -// PipeStrict sets strict pipeline semantics (stop on first failure). +// PipeStrict selects the first stage with an execution error or non-zero exit as +// the primary result. Every stage is still started concurrently. // @group Pipelining // // Example: strict @@ -494,7 +507,8 @@ func (c *Cmd) PipeStrict() *Cmd { return c } -// PipeBestEffort sets best-effort pipeline semantics (run all stages, surface the first error). +// PipeBestEffort selects the last stage as the primary result while surfacing the +// first execution error. Every stage is started concurrently. // @group Pipelining // // Example: best effort @@ -699,7 +713,8 @@ func WithFormatter(fn func(ShadowEvent) string) ShadowOption { } } -// Run executes the command and returns the result and any error. +// Run executes the command and returns the result and any execution error. A +// non-zero exit code is represented in Result and does not itself produce an error. // @group Execution // // Example: run @@ -759,7 +774,9 @@ func (c *Cmd) OutputTrimmed() (string, error) { return strings.TrimSpace(result.Stdout), err } -// CombinedOutput executes the command and returns stdout+stderr and any error. +// CombinedOutput executes the command and returns stdout and stderr in observed +// chunk order plus any execution error. Exact byte interleaving between the two +// operating-system streams is inherently scheduler-dependent. // @group Execution // // Example: combined output @@ -784,7 +801,8 @@ func (c *Cmd) CombinedOutput() (string, error) { return combined, result.Err } -// PipelineResults executes the command and returns per-stage results and any error. +// PipelineResults executes the command and returns per-stage results and the first +// execution error. Non-zero exit codes remain data in their corresponding Result. // @group Pipelining // // Example: pipeline results @@ -810,7 +828,8 @@ func (c *Cmd) PipelineResults() ([]Result, error) { return results, firstResultErr(results) } -// Start executes the command asynchronously. +// Start executes the command asynchronously. Startup still completes synchronously, +// so a returned Process represents either a fully started pipeline or a completed error. // @group Execution // // Example: start @@ -843,6 +862,7 @@ func (c *Cmd) Start() *Process { return proc } +// ctxOrBackground normalizes an omitted context at the os/exec boundary. func (c *Cmd) ctxOrBackground() context.Context { if c.ctx == nil { return context.Background() @@ -850,6 +870,18 @@ func (c *Cmd) ctxOrBackground() context.Context { return c.ctx } +// baseContext keeps replacement timeouts attached to the context that originally owned them. +func (c *Cmd) baseContext() context.Context { + if c.cancel != nil && c.ctxParent != nil { + return c.ctxParent + } + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + +// rootCmd resolves chain-wide options without requiring callers to retain the first stage. func (c *Cmd) rootCmd() *Cmd { if c.root != nil { return c.root @@ -857,6 +889,7 @@ func (c *Cmd) rootCmd() *Cmd { return c } +// execCmd creates a fresh standard-library command for one execution attempt. func (c *Cmd) execCmd() *exec.Cmd { cmd := exec.CommandContext(c.ctxOrBackground(), c.name, c.args...) if c.dir != "" { @@ -876,6 +909,7 @@ var isTerminalFunc = term.IsTerminal var openPTYFunc = openPTY var ptyCheckFunc = ptyCheck +// isTerminalWriter identifies direct file-backed terminal output eligible for passthrough. func isTerminalWriter(w io.Writer) bool { f, ok := w.(*os.File) if !ok { @@ -884,6 +918,7 @@ func isTerminalWriter(w io.Writer) bool { return isTerminalFunc(int(f.Fd())) } +// validatePTY rejects unsupported combinations before any subprocess starts. func (c *Cmd) validatePTY() error { root := c.rootCmd() if !root.usePTY { @@ -898,74 +933,107 @@ func (c *Cmd) validatePTY() error { return nil } +// stdoutWriter preserves the original helper boundary for focused writer tests. func (c *Cmd) stdoutWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer { + out, _ := c.stdoutWriterWithFlush(buf, withCombined, combined, shadow) + return out +} + +// stdoutWriterWithFlush builds stdout fan-out and retains the callback's final-line flush. +func (c *Cmd) stdoutWriterWithFlush(buf *bytes.Buffer, withCombined bool, combined io.Writer, shadow *shadowContext) (io.Writer, func()) { if c.stdoutW != nil && c.onStdout == nil && !withCombined { if isTerminalWriter(c.stdoutW) { - return c.stdoutW + return c.stdoutW, nil } } - writers := []io.Writer{} - if c.stdoutW != nil { - writers = append(writers, c.stdoutW) - } - writers = append(writers, buf) + writers := []io.Writer{buf} if withCombined { writers = append(writers, combined) } + if c.stdoutW != nil { + writers = append(writers, c.stdoutW) + } + var flush func() if c.onStdout != nil { - writers = append(writers, &lineWriter{onLine: c.onStdout}) + lines := &lineWriter{onLine: c.onStdout} + writers = append(writers, lines) + flush = lines.Flush } var out io.Writer = buf if len(writers) > 1 { out = io.MultiWriter(writers...) } - return wrapShadowWriter(out, shadow) + return wrapShadowWriter(out, shadow), flush } +// stderrWriter preserves the original helper boundary for focused writer tests. func (c *Cmd) stderrWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer { + out, _ := c.stderrWriterWithFlush(buf, withCombined, combined, shadow) + return out +} + +// stderrWriterWithFlush builds stderr fan-out and retains the callback's final-line flush. +func (c *Cmd) stderrWriterWithFlush(buf *bytes.Buffer, withCombined bool, combined io.Writer, shadow *shadowContext) (io.Writer, func()) { if c.stderrW != nil && c.onStderr == nil && !withCombined { if isTerminalWriter(c.stderrW) { - return c.stderrW + return c.stderrW, nil } } - writers := []io.Writer{} - if c.stderrW != nil { - writers = append(writers, c.stderrW) - } - writers = append(writers, buf) + writers := []io.Writer{buf} if withCombined { writers = append(writers, combined) } + if c.stderrW != nil { + writers = append(writers, c.stderrW) + } + var flush func() if c.onStderr != nil { - writers = append(writers, &lineWriter{onLine: c.onStderr}) + lines := &lineWriter{onLine: c.onStderr} + writers = append(writers, lines) + flush = lines.Flush } var out io.Writer = buf if len(writers) > 1 { out = io.MultiWriter(writers...) } - return wrapShadowWriter(out, shadow) + return wrapShadowWriter(out, shadow), flush } -func (c *Cmd) ptyWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer { - writers := []io.Writer{} +// ptyWriterWithFlush builds merged PTY fan-out and retains the callback's final-line flush. +func (c *Cmd) ptyWriterWithFlush(buf *bytes.Buffer, withCombined bool, combined io.Writer, shadow *shadowContext) (io.Writer, func()) { + writers := []io.Writer{buf} + if withCombined { + writers = append(writers, combined) + } if c.stdoutW != nil { writers = append(writers, c.stdoutW) } - if c.stderrW != nil && c.stderrW != c.stdoutW { + if c.stderrW != nil && !sameWriter(c.stderrW, c.stdoutW) { writers = append(writers, c.stderrW) } - writers = append(writers, buf) - if withCombined { - writers = append(writers, combined) - } + var flush func() if c.onStdout != nil || c.onStderr != nil { - writers = append(writers, &ptyLineWriter{onStdout: c.onStdout, onStderr: c.onStderr}) + lines := &ptyLineWriter{onStdout: c.onStdout, onStderr: c.onStderr} + writers = append(writers, lines) + flush = lines.Flush } var out io.Writer = buf if len(writers) > 1 { out = io.MultiWriter(writers...) } - return wrapShadowWriter(out, shadow) + return wrapShadowWriter(out, shadow), flush +} + +// sameWriter avoids interface-comparison panics for writer implementations with non-comparable values. +func sameWriter(left io.Writer, right io.Writer) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + leftType := reflect.TypeOf(left) + if leftType != reflect.TypeOf(right) || !leftType.Comparable() { + return false + } + return left == right } type lineWriter struct { @@ -991,6 +1059,16 @@ func (l *lineWriter) Write(p []byte) (int, error) { return len(p), nil } +// Flush emits a final unterminated line after the command closes its output stream. +func (l *lineWriter) Flush() { + if l.onLine == nil || l.buf.Len() == 0 { + return + } + line := strings.TrimSuffix(l.buf.String(), "\r") + l.buf.Reset() + l.onLine(line) +} + type ptyLineWriter struct { onStdout func(string) onStderr func(string) @@ -1020,6 +1098,22 @@ func (l *ptyLineWriter) Write(p []byte) (int, error) { return len(p), nil } +// Flush emits a final unterminated PTY line to both configured callbacks. +func (l *ptyLineWriter) Flush() { + if l.buf.Len() == 0 { + return + } + line := strings.TrimSuffix(l.buf.String(), "\r") + l.buf.Reset() + if l.onStdout != nil { + l.onStdout(line) + } + if l.onStderr != nil { + l.onStderr(line) + } +} + +// buildEnv sorts the merged environment so inspection and execution use deterministic input. func buildEnv(mode envMode, env map[string]string) []string { merged := map[string]string{} if mode != envOnly { @@ -1043,6 +1137,7 @@ func buildEnv(mode envMode, env map[string]string) []string { return list } +// firstResultErr preserves stage order when reporting pipeline execution errors. func firstResultErr(results []Result) error { for _, res := range results { if res.Err != nil { @@ -1054,6 +1149,7 @@ func firstResultErr(results []Result) error { var shellEscapePattern = regexp.MustCompile(`[^\w@%+=:,./-]`) +// shellEscape produces a POSIX-oriented display form that is never used for execution. func shellEscape(arg string) string { if arg == "" { return "''" @@ -1090,11 +1186,16 @@ const ( // ShadowEvent captures details for ShadowPrint formatting. type ShadowEvent struct { - Command string + // Command contains the display command after masking. + Command string + // RawCommand contains the unmasked display command and may include secrets. RawCommand string - Phase ShadowPhase - Duration time.Duration - Async bool + // Phase distinguishes output before execution from output after completion. + Phase ShadowPhase + // Duration contains elapsed execution time for ShadowAfter events. + Duration time.Duration + // Async reports whether execution was started through Start. + Async bool } // ShadowOption configures ShadowPrint behavior. @@ -1106,10 +1207,12 @@ type shadowConfig struct { formatter func(ShadowEvent) string } +// defaultShadowConfig centralizes presentation defaults for first-time and resumed shadow output. func defaultShadowConfig() shadowConfig { return shadowConfig{prefix: "execx"} } +// shadowPrintStart snapshots timing and formatting state before execution begins. func (c *Cmd) shadowPrintStart(async bool) *shadowContext { root := c.rootCmd() if root == nil || !root.shadowPrint { @@ -1127,6 +1230,7 @@ func (c *Cmd) shadowPrintStart(async bool) *shadowContext { return ctx } +// shadowCommand renders every stage because shadow output describes the whole pipeline. func (c *Cmd) shadowCommand() string { root := c.rootCmd() parts := []string{} @@ -1136,6 +1240,7 @@ func (c *Cmd) shadowCommand() string { return strings.Join(parts, " | ") } +// finish preserves visual separation before printing the terminal shadow event. func (s *shadowContext) finish() { if s == nil || s.cmd == nil { return @@ -1159,6 +1264,7 @@ type shadowOutputWriter struct { w io.Writer } +// wrapShadowWriter tracks output only when the default formatter owns spacing. func wrapShadowWriter(out io.Writer, shadow *shadowContext) io.Writer { if shadow != nil && shadow.spacing { return &shadowOutputWriter{ctx: shadow, w: out} @@ -1183,6 +1289,7 @@ func (s *shadowOutputWriter) Write(p []byte) (int, error) { return s.w.Write(p) } +// shadowPrintLine routes an event through custom formatting or the default terminal presentation. func shadowPrintLine(cmd *Cmd, phase ShadowPhase, duration time.Duration, async bool) { if cmd == nil { return @@ -1233,7 +1340,8 @@ func shadowPrintLine(cmd *Cmd, phase ShadowPhase, duration time.Duration, async ) } -// Process represents an asynchronously running command. +// Process represents an asynchronously running command. Its process-control and +// Wait methods may be called from separate goroutines. type Process struct { pipeline *pipeline mode pipeMode @@ -1356,6 +1464,7 @@ func (p *Process) GracefulShutdown(sig os.Signal, timeout time.Duration) error { return nil } +// finish publishes one immutable result and closes the synchronization channel exactly once. func (p *Process) finish(result Result) { p.resultOnce.Do(func() { p.result = result @@ -1364,6 +1473,7 @@ func (p *Process) finish(result Result) { }) } +// signalAll applies process control to each started pipeline stage while retaining the first failure. func (p *Process) signalAll(send func(*os.Process) error) error { if p == nil || p.pipeline == nil { return errors.New("process not started") diff --git a/execx_shadow_test.go b/execx_shadow_test.go index ec59329..4c18a90 100644 --- a/execx_shadow_test.go +++ b/execx_shadow_test.go @@ -13,6 +13,7 @@ import ( var stderrMu sync.Mutex +// captureStderr serializes global descriptor replacement so shadow tests cannot interfere. func captureStderr(t *testing.T, fn func()) string { t.Helper() @@ -38,11 +39,13 @@ func captureStderr(t *testing.T, fn func()) string { return buf.String() } +// stripANSI removes presentation codes before assertions inspect shadow text. func stripANSI(s string) string { re := regexp.MustCompile(`\x1b\[[0-9;]*m`) return re.ReplaceAllString(s, "") } +// TestShadowPrintDefault ensures the default trace includes the command and elapsed duration. func TestShadowPrintDefault(t *testing.T) { out := captureStderr(t, func() { _, _ = Command("printf", "hi").ShadowPrint().Run() @@ -56,6 +59,7 @@ func TestShadowPrintDefault(t *testing.T) { } } +// TestShadowPrintDefaultSpacing ensures command output remains visually separated from trace lines. func TestShadowPrintDefaultSpacing(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("spacing test uses sh") @@ -91,6 +95,7 @@ func TestShadowPrintDefaultSpacing(t *testing.T) { } } +// TestShadowPrintPrefix ensures callers can identify traces with a custom prefix. func TestShadowPrintPrefix(t *testing.T) { out := captureStderr(t, func() { _, _ = Command("printf", "hi").ShadowPrint(WithPrefix("run")).Run() @@ -101,6 +106,7 @@ func TestShadowPrintPrefix(t *testing.T) { } } +// TestShadowPrintOff ensures disabled tracing produces no diagnostic output. func TestShadowPrintOff(t *testing.T) { out := captureStderr(t, func() { _, _ = Command("printf", "hi").ShadowPrint().ShadowOff().Run() @@ -110,6 +116,7 @@ func TestShadowPrintOff(t *testing.T) { } } +// TestShadowPrintMask ensures secrets can be redacted before commands reach diagnostic output. func TestShadowPrintMask(t *testing.T) { out := captureStderr(t, func() { mask := func(cmd string) string { @@ -123,6 +130,7 @@ func TestShadowPrintMask(t *testing.T) { } } +// TestShadowPrintFormatter ensures custom formatting receives both lifecycle phases. func TestShadowPrintFormatter(t *testing.T) { out := captureStderr(t, func() { formatter := func(ev ShadowEvent) string { @@ -144,6 +152,7 @@ func TestShadowPrintFormatter(t *testing.T) { } } +// TestShadowPrintFormatterEmpty ensures an empty formatted line suppresses that trace event. func TestShadowPrintFormatterEmpty(t *testing.T) { out := captureStderr(t, func() { formatter := func(ev ShadowEvent) string { @@ -156,6 +165,7 @@ func TestShadowPrintFormatterEmpty(t *testing.T) { } } +// TestShadowCommandPipeline ensures traces describe the complete pipeline in execution order. func TestShadowCommandPipeline(t *testing.T) { cmd := Command("printf", "go").Pipe("tr", "a-z", "A-Z") if got := cmd.shadowCommand(); got != "printf go | tr a-z A-Z" { @@ -163,6 +173,7 @@ func TestShadowCommandPipeline(t *testing.T) { } } +// TestShadowPrintAsync ensures Start traces are distinguishable from synchronous execution. func TestShadowPrintAsync(t *testing.T) { out := captureStderr(t, func() { proc := Command("sleep", "0.01").ShadowPrint().Start() @@ -174,6 +185,7 @@ func TestShadowPrintAsync(t *testing.T) { } } +// TestShadowOffOnPreservesConfig ensures temporarily disabling tracing does not discard its options. func TestShadowOffOnPreservesConfig(t *testing.T) { out := captureStderr(t, func() { cmd := Command("printf", "hi").ShadowPrint(WithPrefix("run")) @@ -186,6 +198,7 @@ func TestShadowOffOnPreservesConfig(t *testing.T) { } } +// TestShadowOnDefaultConfig ensures enabling an unconfigured command installs safe defaults. func TestShadowOnDefaultConfig(t *testing.T) { out := captureStderr(t, func() { cmd := Command("printf", "hi") @@ -198,6 +211,7 @@ func TestShadowOnDefaultConfig(t *testing.T) { } } +// TestShadowPrintMaskWithFormatter ensures formatters receive redacted and raw forms for deliberate handling. func TestShadowPrintMaskWithFormatter(t *testing.T) { out := captureStderr(t, func() { mask := func(cmd string) string { @@ -214,6 +228,7 @@ func TestShadowPrintMaskWithFormatter(t *testing.T) { } } +// TestShadowPrintEmptyPrefix ensures an empty custom prefix falls back to the recognizable default. func TestShadowPrintEmptyPrefix(t *testing.T) { out := captureStderr(t, func() { _, _ = Command("printf", "hi").ShadowPrint(WithPrefix("")).Run() @@ -224,6 +239,7 @@ func TestShadowPrintEmptyPrefix(t *testing.T) { } } +// TestShadowPrintLineNil ensures absent shadow configuration remains a harmless no-op. func TestShadowPrintLineNil(t *testing.T) { shadowPrintLine(nil, ShadowBefore, 0, false) } diff --git a/execx_test.go b/execx_test.go index 45a5b73..da0ee56 100644 --- a/execx_test.go +++ b/execx_test.go @@ -17,6 +17,7 @@ import ( "time" ) +// TestHelperProcess provides deterministic subprocess behavior for stream, signal, and exit tests. func TestHelperProcess(t *testing.T) { if os.Getenv("EXECX_TEST_HELPER") != "1" { return @@ -59,10 +60,7 @@ func TestHelperProcess(t *testing.T) { wd, _ := os.Getwd() _, _ = io.WriteString(os.Stdout, wd) case "signal": - if runtime.GOOS == "windows" { - os.Exit(3) - } - _ = syscall.Kill(os.Getpid(), syscall.SIGTERM) + terminateHelperProcess() time.Sleep(50 * time.Millisecond) case "ignore-term": if runtime.GOOS == "windows" { @@ -76,6 +74,7 @@ func TestHelperProcess(t *testing.T) { os.Exit(0) } +// helperCommand routes deterministic subprocess behavior through the current test binary. func helperCommand(args ...string) *Cmd { full := append([]string{"-test.run=TestHelperProcess", "--"}, args...) cmd := Command(os.Args[0], full...) @@ -83,6 +82,7 @@ func helperCommand(args ...string) *Cmd { return cmd } +// helperPipe appends a deterministic helper subprocess as one pipeline stage. func helperPipe(cmd *Cmd, args ...string) *Cmd { full := append([]string{"-test.run=TestHelperProcess", "--"}, args...) stage := cmd.Pipe(os.Args[0], full...) @@ -92,10 +92,12 @@ func helperPipe(cmd *Cmd, args ...string) *Cmd { type envStringer struct{} +// String provides an environment entry through Env's fmt.Stringer fallback. func (envStringer) String() string { return "EXECX_ENV_VALUE=stringer" } +// TestArgOrderAndArgs ensures heterogeneous arguments retain deterministic command-line order. func TestArgOrderAndArgs(t *testing.T) { cmd := helperCommand("echo").Arg("alpha").Arg(map[string]string{"--b": "2", "--a": "1"}) out, err := cmd.Output() @@ -111,6 +113,7 @@ func TestArgOrderAndArgs(t *testing.T) { } } +// TestArgVariants ensures slices and scalar values share the fluent argument path. func TestArgVariants(t *testing.T) { out, err := helperCommand("echo").Arg([]string{"a", "b"}, 123).Output() if err != nil { @@ -121,6 +124,7 @@ func TestArgVariants(t *testing.T) { } } +// TestEnvModes ensures inheritance, replacement, and appended overrides remain distinct policies. func TestEnvModes(t *testing.T) { key := "EXECX_ENV_VALUE" t.Setenv(key, "base") @@ -146,6 +150,7 @@ func TestEnvModes(t *testing.T) { } } +// TestEnvVariants ensures supported environment input forms resolve with deterministic precedence. func TestEnvVariants(t *testing.T) { cmd := Command(os.Args[0], "-test.run=TestHelperProcess", "--", "env", "EXECX_ENV_VALUE"). Env(envStringer{}). @@ -160,6 +165,7 @@ func TestEnvVariants(t *testing.T) { } } +// TestEnvAppendEmpty ensures append mode starts from the process environment when no explicit map exists. func TestEnvAppendEmpty(t *testing.T) { cmd := Command(os.Args[0], "-test.run=TestHelperProcess", "--", "env", "EXECX_ENV_VALUE"). EnvAppend(map[string]string{"EXECX_ENV_VALUE": "append", "EXECX_TEST_HELPER": "1"}) @@ -172,6 +178,7 @@ func TestEnvAppendEmpty(t *testing.T) { } } +// TestEnvList ensures environment inspection is sorted and therefore reproducible. func TestEnvList(t *testing.T) { cmd := helperCommand("env", "NONE").EnvOnly(map[string]string{"B": "2", "A": "1", "EXECX_TEST_HELPER": "1"}) list := cmd.EnvList() @@ -180,6 +187,7 @@ func TestEnvList(t *testing.T) { } } +// TestStdinHelpers ensures every supported input source reaches the child process unchanged. func TestStdinHelpers(t *testing.T) { cases := []struct { name string @@ -234,6 +242,22 @@ func TestStdinHelpers(t *testing.T) { } } +// TestStdinBytesCopiesInput ensures later caller mutation cannot alter a configured command. +func TestStdinBytesCopiesInput(t *testing.T) { + input := []byte("before") + cmd := helperCommand("cat").StdinBytes(input) + copy(input, "mutate") + + out, err := cmd.Output() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out != "before" { + t.Fatalf("expected configured bytes to be stable, got %q", out) + } +} + +// TestOutputVariants ensures string, byte, trimmed, and combined capture preserve their distinct contracts. func TestOutputVariants(t *testing.T) { out, err := helperCommand("echo", " spaced ").OutputTrimmed() if err != nil { @@ -260,6 +284,7 @@ func TestOutputVariants(t *testing.T) { } } +// TestExitHelpers ensures ordinary nonzero exits remain results rather than execution failures. func TestExitHelpers(t *testing.T) { res, err := helperCommand("exit", "2").Run() if err != nil { @@ -276,6 +301,7 @@ func TestExitHelpers(t *testing.T) { } } +// TestIsSignal ensures signal termination remains distinguishable from numeric exit status. func TestIsSignal(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -289,6 +315,7 @@ func TestIsSignal(t *testing.T) { } } +// TestWithTimeout ensures the shortest configured timeout bounds process execution. func TestWithTimeout(t *testing.T) { _, err := helperCommand("sleep", "200").WithTimeout(50 * time.Millisecond).Run() if err == nil { @@ -304,18 +331,35 @@ func TestWithTimeout(t *testing.T) { } } +// TestWithDeadline ensures expired deadlines stop work while later replacements permit valid commands. func TestWithDeadline(t *testing.T) { _, err := helperCommand("sleep", "100").WithDeadline(time.Now().Add(10 * time.Millisecond)).Run() if err == nil { t.Fatalf("expected deadline error") } - _, err = helperCommand("echo", "ok").WithDeadline(time.Now().Add(200 * time.Millisecond)).WithDeadline(time.Now().Add(300 * time.Millisecond)).Run() + _, err = helperCommand("echo", "ok").WithDeadline(time.Now().Add(2 * time.Second)).WithDeadline(time.Now().Add(3 * time.Second)).Run() if err != nil { t.Fatalf("expected no error, got %v", err) } } +// TestTimeoutPreservesCanceledParent ensures derived timing options cannot hide parent cancellation. +func TestTimeoutPreservesCanceledParent(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cmd := helperCommand("sleep", "200"). + WithContext(ctx). + WithTimeout(time.Second). + WithDeadline(time.Now().Add(2 * time.Second)) + cancel() + + _, err := cmd.Run() + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled parent to remain authoritative, got %v", err) + } +} + +// TestWithContext ensures replacing command context consistently controls subsequent execution. func TestWithContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -330,6 +374,7 @@ func TestWithContext(t *testing.T) { } } +// TestDir ensures the child process observes the configured working directory. func TestDir(t *testing.T) { temp := t.TempDir() out, err := helperCommand("pwd").Dir(temp).Output() @@ -349,6 +394,7 @@ func TestDir(t *testing.T) { } } +// TestPipeModes ensures strict pipelines report any stage failure while best-effort mode follows the last stage. func TestPipeModes(t *testing.T) { strictRes, err := helperPipe(helperCommand("exit", "2"), "echo", "ok").Run() if err != nil { @@ -370,6 +416,7 @@ func TestPipeModes(t *testing.T) { } } +// TestPipeChain ensures appended stages preserve pipeline order and final output ownership. func TestPipeChain(t *testing.T) { root := helperCommand("echo", "a") stage := helperPipe(root, "echo", "b") @@ -383,6 +430,7 @@ func TestPipeChain(t *testing.T) { } } +// TestPipeBestEffortSetsError ensures context failure remains visible even when the final stage succeeds. func TestPipeBestEffortSetsError(t *testing.T) { res, err := helperPipe(helperCommand("sleep", "50").WithTimeout(10*time.Millisecond).PipeBestEffort(), "echo", "ok").Run() if err == nil || !errorsIsContext(err) { @@ -393,6 +441,7 @@ func TestPipeBestEffortSetsError(t *testing.T) { } } +// TestPipeStartError ensures an unstartable stage produces an execution error and sentinel exit code. func TestPipeStartError(t *testing.T) { bad := Command("execx-does-not-exist") stage := helperPipe(bad, "echo", "ok") @@ -409,6 +458,37 @@ func TestPipeStartError(t *testing.T) { } } +// TestPipelineDownstreamStartErrorAbortsUpstream ensures partial startup cannot leave an earlier process running. +func TestPipelineDownstreamStartErrorAbortsUpstream(t *testing.T) { + cmd := helperCommand("sleep", "5000").Pipe("execx-does-not-exist") + type outcome struct { + result Result + err error + } + done := make(chan outcome, 1) + go func() { + result, err := cmd.Run() + done <- outcome{result: result, err: err} + }() + + select { + case got := <-done: + if got.err == nil { + t.Fatal("expected downstream start error") + } + var execErr ErrExec + if !errors.As(got.err, &execErr) { + t.Fatalf("expected ErrExec, got %T", got.err) + } + if got.result.ExitCode != -1 { + t.Fatalf("expected failed stage result, got exit code %d", got.result.ExitCode) + } + case <-time.After(2 * time.Second): + t.Fatal("pipeline did not abort after downstream start failure") + } +} + +// TestStringAndShellEscaped ensures display quoting and shell-safe quoting remain intentionally different. func TestStringAndShellEscaped(t *testing.T) { cmd := Command("echo", "hello world", "it's") if cmd.String() != "echo \"hello world\" it's" { @@ -428,6 +508,7 @@ func TestStringAndShellEscaped(t *testing.T) { } } +// TestLineCallbacks ensures stdout and stderr lines reach only their respective callbacks. func TestLineCallbacks(t *testing.T) { var stdoutLines []string var stderrLines []string @@ -447,6 +528,43 @@ func TestLineCallbacks(t *testing.T) { } } +// TestLineCallbacksFlushFinalLine ensures unterminated trailing output is not lost at process exit. +func TestLineCallbacksFlushFinalLine(t *testing.T) { + var stdoutLines []string + var stderrLines []string + _, err := helperCommand("echo", "tail"). + OnStdout(func(line string) { stdoutLines = append(stdoutLines, line) }). + Run() + if err != nil { + t.Fatalf("expected no stdout error, got %v", err) + } + _, err = helperCommand("stderr", "tail"). + OnStderr(func(line string) { stderrLines = append(stderrLines, line) }). + Run() + if err != nil { + t.Fatalf("expected no stderr error, got %v", err) + } + if strings.Join(stdoutLines, ",") != "tail" || strings.Join(stderrLines, ",") != "tail" { + t.Fatalf("expected final lines, got stdout=%v stderr=%v", stdoutLines, stderrLines) + } +} + +// TestOutputCallbacksAreSerialized ensures shared callback state is safe across concurrent stdout and stderr reads. +func TestOutputCallbacksAreSerialized(t *testing.T) { + var lines []string + callback := func(line string) { + lines = append(lines, line) + } + _, err := helperCommand("lines").OnStdout(callback).OnStderr(callback).Run() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(lines) != 3 { + t.Fatalf("expected every output line, got %v", lines) + } +} + +// TestWritersBeforeLineCallbacks ensures byte writers observe output before derived line notifications. func TestWritersBeforeLineCallbacks(t *testing.T) { var order []string var stdoutLines []string @@ -468,6 +586,7 @@ func TestWritersBeforeLineCallbacks(t *testing.T) { } } +// TestStderrWriter ensures raw stderr forwarding and line callbacks can coexist without data loss. func TestStderrWriter(t *testing.T) { var stderrLines []string writer := &orderedWriter{tag: "stderr"} @@ -485,6 +604,7 @@ func TestStderrWriter(t *testing.T) { } } +// TestStartAndWait ensures asynchronous execution reports the same clean result as Run. func TestStartAndWait(t *testing.T) { proc := helperCommand("sleep", "50").Start() res, err := proc.Wait() @@ -493,6 +613,7 @@ func TestStartAndWait(t *testing.T) { } } +// TestStartError ensures startup failures retain ErrExec identity and an unavailable exit code. func TestStartError(t *testing.T) { res, err := Command("execx-does-not-exist").Run() if err == nil { @@ -507,6 +628,7 @@ func TestStartError(t *testing.T) { } } +// TestLineWriterNil ensures a line splitter without a callback still satisfies io.Writer safely. func TestLineWriterNil(t *testing.T) { writer := &lineWriter{} n, err := writer.Write([]byte("data")) @@ -515,6 +637,7 @@ func TestLineWriterNil(t *testing.T) { } } +// TestWithPTYPipelineUnsupported ensures PTY mode rejects pipelines whose stream topology cannot be represented safely. func TestWithPTYPipelineUnsupported(t *testing.T) { prevCheck := ptyCheckFunc ptyCheckFunc = func() error { return nil } @@ -530,6 +653,7 @@ func TestWithPTYPipelineUnsupported(t *testing.T) { } } +// TestWithPTYOpenError ensures terminal allocation failures abort execution with their cause intact. func TestWithPTYOpenError(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -547,6 +671,7 @@ func TestWithPTYOpenError(t *testing.T) { } } +// TestWithPTYCombinedStream ensures PTY output is captured once while notifying both logical stream callbacks. func TestWithPTYCombinedStream(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -586,6 +711,7 @@ func TestWithPTYCombinedStream(t *testing.T) { } } +// TestWithPTYCombinedOutput ensures the combined-output convenience API works with a single PTY stream. func TestWithPTYCombinedOutput(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -610,6 +736,7 @@ func TestWithPTYCombinedOutput(t *testing.T) { } } +// TestWithPTYPipelineResults ensures a one-stage PTY command retains the pipeline-results API contract. func TestWithPTYPipelineResults(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -634,6 +761,7 @@ func TestWithPTYPipelineResults(t *testing.T) { } } +// TestWithPTYStart ensures asynchronous PTY capture is complete before Wait returns. func TestWithPTYStart(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -656,6 +784,7 @@ func TestWithPTYStart(t *testing.T) { } } +// TestWithPTYCheckError ensures unsupported terminals fail before process startup. func TestWithPTYCheckError(t *testing.T) { prevCheck := ptyCheckFunc ptyCheckFunc = func() error { return errors.New("pty unsupported") } @@ -668,6 +797,7 @@ func TestWithPTYCheckError(t *testing.T) { } } +// TestWithPTYStartCheckError ensures asynchronous PTY validation failures surface through Wait. func TestWithPTYStartCheckError(t *testing.T) { prevCheck := ptyCheckFunc ptyCheckFunc = func() error { return errors.New("pty unsupported") } @@ -684,6 +814,7 @@ func TestWithPTYStartCheckError(t *testing.T) { } } +// TestWithPTYPipelineResultsCheckError ensures PTY validation also protects the pipeline-results entry point. func TestWithPTYPipelineResultsCheckError(t *testing.T) { prevCheck := ptyCheckFunc ptyCheckFunc = func() error { return errors.New("pty unsupported") } @@ -700,11 +831,27 @@ type errWriter struct { called bool } +// Write records the attempt before returning a deterministic output failure. func (w *errWriter) Write(p []byte) (int, error) { w.called = true return 0, errors.New("write failed") } +type errReader struct{} + +// Read returns a deterministic stdin-copy failure. +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("read failed") +} + +type sliceWriter []byte + +// Write accepts bytes without mutating its non-comparable value receiver. +func (sliceWriter) Write(p []byte) (int, error) { + return len(p), nil +} + +// TestWithPTYWriterError ensures forwarding failures become ErrExec without discarding captured output. func TestWithPTYWriterError(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -722,17 +869,50 @@ func TestWithPTYWriterError(t *testing.T) { }) writer := &errWriter{} res, err := Command("printf", "hi").WithPTY().StdoutWriter(writer).Run() - if err != nil { - t.Fatalf("expected no error, got %v", err) + if err == nil { + t.Fatal("expected writer error") + } + var execErr ErrExec + if !errors.As(err, &execErr) { + t.Fatalf("expected ErrExec, got %T", err) } if !writer.called { t.Fatalf("expected writer to be called") } - if res.Stdout != "" { - t.Fatalf("expected empty stdout, got %q", res.Stdout) + if res.Stdout != "hi" { + t.Fatalf("expected captured stdout before writer failure, got %q", res.Stdout) + } +} + +// TestWriterErrorIsExecutionError ensures ordinary stream-writer failures use the same ErrExec boundary as process failures. +func TestWriterErrorIsExecutionError(t *testing.T) { + writer := &errWriter{} + res, err := helperCommand("echo", "hi").StdoutWriter(writer).Run() + if err == nil { + t.Fatal("expected writer error") + } + var execErr ErrExec + if !errors.As(err, &execErr) { + t.Fatalf("expected ErrExec, got %T", err) + } + if res.Stdout != "hi" { + t.Fatalf("expected result capture before writer failure, got %q", res.Stdout) } } +// TestReaderErrorIsExecutionError ensures stdin-copy failures cannot be mistaken for successful execution. +func TestReaderErrorIsExecutionError(t *testing.T) { + _, err := helperCommand("cat").StdinReader(errReader{}).Run() + if err == nil { + t.Fatal("expected reader error") + } + var execErr ErrExec + if !errors.As(err, &execErr) { + t.Fatalf("expected ErrExec, got %T", err) + } +} + +// TestWithPTYStartError ensures process startup failures remain visible after successful terminal allocation. func TestWithPTYStartError(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -754,6 +934,7 @@ func TestWithPTYStartError(t *testing.T) { } } +// TestWithPTYWritersNoCallbacks ensures both configured writers receive the merged PTY stream without callbacks. func TestWithPTYWritersNoCallbacks(t *testing.T) { prevOpen := openPTYFunc prevCheck := ptyCheckFunc @@ -787,6 +968,45 @@ func TestWithPTYWritersNoCallbacks(t *testing.T) { } } +// TestWithPTYSharedWriterAndCallback ensures a shared writer receives merged bytes once while callbacks remain serialized. +func TestWithPTYSharedWriterAndCallback(t *testing.T) { + prevOpen := openPTYFunc + prevCheck := ptyCheckFunc + openPTYFunc = func() (*os.File, *os.File, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, nil, err + } + return r, w, nil + } + ptyCheckFunc = func() error { return nil } + t.Cleanup(func() { + openPTYFunc = prevOpen + ptyCheckFunc = prevCheck + }) + + var output bytes.Buffer + var lines []string + callback := func(line string) { lines = append(lines, line) } + _, err := Command("printf", "a\nb"). + WithPTY(). + StdoutWriter(&output). + StderrWriter(&output). + OnStdout(callback). + OnStderr(callback). + Run() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if output.String() != "a\nb" { + t.Fatalf("expected shared writer to receive one merged stream, got %q", output.String()) + } + if strings.Join(lines, ",") != "a,a,b,b" { + t.Fatalf("expected serialized PTY callbacks, got %v", lines) + } +} + +// TestPTYLineWriterNil ensures an unconfigured PTY splitter remains a safe io.Writer. func TestPTYLineWriterNil(t *testing.T) { writer := &ptyLineWriter{} n, err := writer.Write([]byte("data")) @@ -795,6 +1015,41 @@ func TestPTYLineWriterNil(t *testing.T) { } } +// TestPTYLineWriterFlush ensures a trailing carriage-return line is emitted exactly once. +func TestPTYLineWriterFlush(t *testing.T) { + var stdoutLines []string + var stderrLines []string + writer := &ptyLineWriter{ + onStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, + onStderr: func(line string) { stderrLines = append(stderrLines, line) }, + } + _, _ = writer.Write([]byte("tail\r")) + writer.Flush() + writer.Flush() + if strings.Join(stdoutLines, ",") != "tail" || strings.Join(stderrLines, ",") != "tail" { + t.Fatalf("expected flushed PTY lines, got stdout=%v stderr=%v", stdoutLines, stderrLines) + } +} + +// TestSameWriter ensures writer deduplication avoids panics for non-comparable implementations. +func TestSameWriter(t *testing.T) { + var first bytes.Buffer + var second bytes.Buffer + if !sameWriter(nil, nil) { + t.Fatal("expected two nil writers to match") + } + if sameWriter(nil, &first) || sameWriter(&first, &second) { + t.Fatal("expected distinct writers not to match") + } + if !sameWriter(&first, &first) { + t.Fatal("expected identical writer pointers to match") + } + if sameWriter(sliceWriter{}, sliceWriter{}) { + t.Fatal("expected non-comparable writer values not to be compared") + } +} + +// TestOnExecCmdApplied ensures the final os/exec command remains customizable before startup. func TestOnExecCmdApplied(t *testing.T) { called := false cmd := Command("printf", "hi").OnExecCmd(func(ec *exec.Cmd) { @@ -817,6 +1072,7 @@ func TestOnExecCmdApplied(t *testing.T) { } } +// TestIsTerminalWriterNonFile ensures terminal probing never assumes arbitrary writers are files. func TestIsTerminalWriterNonFile(t *testing.T) { var buf bytes.Buffer if isTerminalWriter(&buf) { @@ -824,6 +1080,7 @@ func TestIsTerminalWriterNonFile(t *testing.T) { } } +// TestStdoutWriterTTYPassthrough ensures interactive stdout is not hidden behind capture buffering. func TestStdoutWriterTTYPassthrough(t *testing.T) { prev := isTerminalFunc isTerminalFunc = func(int) bool { return true } @@ -837,6 +1094,7 @@ func TestStdoutWriterTTYPassthrough(t *testing.T) { } } +// TestStderrWriterTTYPassthrough ensures interactive stderr is not hidden behind capture buffering. func TestStderrWriterTTYPassthrough(t *testing.T) { prev := isTerminalFunc isTerminalFunc = func(int) bool { return true } @@ -850,12 +1108,14 @@ func TestStderrWriterTTYPassthrough(t *testing.T) { } } +// TestSignalFromStateNil ensures absent process state cannot fabricate a termination signal. func TestSignalFromStateNil(t *testing.T) { if signalFromState(nil) != nil { t.Fatalf("expected nil signal") } } +// TestRootCmd ensures an unpiped command is its own pipeline root. func TestRootCmd(t *testing.T) { cmd := &Cmd{} if cmd.rootCmd() != cmd { @@ -863,6 +1123,7 @@ func TestRootCmd(t *testing.T) { } } +// TestStageResultContextError ensures cancellation takes precedence when no process state exists. func TestStageResultContextError(t *testing.T) { st := &stage{ waitErr: context.Canceled, @@ -878,6 +1139,7 @@ func TestStageResultContextError(t *testing.T) { } } +// TestPipelineResults ensures callers receive one ordered result per pipeline stage. func TestPipelineResults(t *testing.T) { root := helperCommand("echo", "a") stage := helperPipe(root, "echo", "b") @@ -894,6 +1156,7 @@ func TestPipelineResults(t *testing.T) { } } +// TestPipelineResultsError ensures a failed single stage still has an inspectable result. func TestPipelineResultsError(t *testing.T) { results, err := Command("execx-does-not-exist").PipelineResults() if err == nil { @@ -911,6 +1174,7 @@ func TestPipelineResultsError(t *testing.T) { } } +// TestPipelineStartErrorPropagation ensures startup failure is recorded on the stage that could not run. func TestPipelineStartErrorPropagation(t *testing.T) { results, err := Command("execx-does-not-exist"). Pipe("printf", "ok"). @@ -926,6 +1190,7 @@ func TestPipelineStartErrorPropagation(t *testing.T) { } } +// TestProcessSignals ensures callers can deliver an explicit operating-system signal to a running process. func TestProcessSignals(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -943,6 +1208,7 @@ func TestProcessSignals(t *testing.T) { } } +// TestProcessInterrupt ensures the convenience API records interrupt termination accurately. func TestProcessInterrupt(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -960,6 +1226,7 @@ func TestProcessInterrupt(t *testing.T) { } } +// TestProcessTerminate ensures forced termination cannot be reported as a clean exit. func TestProcessTerminate(t *testing.T) { proc := helperCommand("sleep", "200").Start() if err := proc.Terminate(); err != nil { @@ -974,6 +1241,7 @@ func TestProcessTerminate(t *testing.T) { } } +// TestGracefulShutdownKills ensures an uncooperative process is killed after its grace period. func TestGracefulShutdownKills(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -992,6 +1260,7 @@ func TestGracefulShutdownKills(t *testing.T) { } } +// TestKillAfter ensures repeated scheduling still terminates a long-running process safely. func TestKillAfter(t *testing.T) { proc := helperCommand("sleep", "200").Start() proc.KillAfter(10 * time.Millisecond) @@ -1005,6 +1274,7 @@ func TestKillAfter(t *testing.T) { } } +// TestGracefulShutdownCompletes ensures cooperative signal exit avoids escalation to a kill. func TestGracefulShutdownCompletes(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -1022,6 +1292,7 @@ func TestGracefulShutdownCompletes(t *testing.T) { } } +// TestGracefulShutdownImmediate ensures a zero grace period escalates without waiting. func TestGracefulShutdownImmediate(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signals not supported on windows") @@ -1039,6 +1310,7 @@ func TestGracefulShutdownImmediate(t *testing.T) { } } +// TestProcessSendErrors ensures missing process and pipeline state return errors instead of panicking. func TestProcessSendErrors(t *testing.T) { var proc *Process if err := proc.Send(os.Interrupt); err == nil { @@ -1053,6 +1325,7 @@ func TestProcessSendErrors(t *testing.T) { } } +// TestProcessSendSkipsStages ensures incomplete pipeline stages are ignored while reporting that nothing was signaled. func TestProcessSendSkipsStages(t *testing.T) { proc := &Process{ pipeline: &pipeline{ @@ -1069,6 +1342,7 @@ func TestProcessSendSkipsStages(t *testing.T) { } } +// TestProcessSendAfterExit ensures signaling a reaped process returns an actionable error. func TestProcessSendAfterExit(t *testing.T) { proc := helperCommand("echo", "ok").Start() _, _ = proc.Wait() @@ -1077,6 +1351,7 @@ func TestProcessSendAfterExit(t *testing.T) { } } +// TestErrExecMethods ensures execution errors preserve wrapping semantics and a useful empty fallback. func TestErrExecMethods(t *testing.T) { baseErr := errors.New("boom") execErr := ErrExec{Err: baseErr} @@ -1095,6 +1370,7 @@ func TestErrExecMethods(t *testing.T) { } } +// TestSysProcAttrNoops ensures platform-specific process options remain harmless where unsupported. func TestSysProcAttrNoops(t *testing.T) { cmd := Command("echo") cmd.CreationFlags(123).HideWindow(true).Pdeathsig(syscall.SIGTERM) @@ -1115,6 +1391,7 @@ type orderedWriter struct { buf []byte } +// Write records first-observer ordering while retaining all received bytes. func (w *orderedWriter) Write(p []byte) (int, error) { if w.order != nil && len(*w.order) == 0 { *w.order = append(*w.order, w.tag) @@ -1123,6 +1400,7 @@ func (w *orderedWriter) Write(p []byte) (int, error) { return len(p), nil } +// TestPipeStrictExplicit ensures explicitly selected strict mode reports an earlier stage failure. func TestPipeStrictExplicit(t *testing.T) { res, err := helperPipe(helperCommand("exit", "2").PipeStrict(), "echo", "ok").Run() if err != nil { @@ -1133,6 +1411,7 @@ func TestPipeStrictExplicit(t *testing.T) { } } +// errorsIsContext accepts either cancellation outcome used by timing-sensitive process tests. func errorsIsContext(err error) bool { return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) } diff --git a/go.mod b/go.mod index ccc3fb9..f569ece 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/goforj/execx go 1.24.4 require ( - golang.org/x/term v0.0.0-20210503060354-a79de5458b56 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect +require golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index fc1e6bc..0cc80d5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pipeline.go b/pipeline.go index 0986023..9c9f1ec 100644 --- a/pipeline.go +++ b/pipeline.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/exec" + "sync" "time" ) @@ -15,25 +16,32 @@ type stage struct { def *Cmd stdoutBuf bytes.Buffer stderrBuf bytes.Buffer - combinedBuf bytes.Buffer + combinedBuf synchronizedBuffer startErr error setupErr error waitErr error + outputErr error startTime time.Time + pipeReader *io.PipeReader pipeWriter *io.PipeWriter ptyMaster *os.File ptySlave *os.File ptyWriter io.Writer ptyDone chan error + flushOutput []func() } type pipeline struct { stages []*stage withCombined bool + outputMu sync.Mutex + startErr error } +// newPipeline materializes fresh exec.Cmd values so a configured command can be inspected before execution. func (c *Cmd) newPipeline(withCombined bool, shadow *shadowContext) *pipeline { stages := c.pipelineStages() + pipe := &pipeline{stages: stages, withCombined: withCombined} for _, stage := range stages { stage.startTime = time.Now() stage.cmd = stage.def.execCmd() @@ -45,14 +53,18 @@ func (c *Cmd) newPipeline(withCombined bool, shadow *shadowContext) *pipeline { } stage.ptyMaster = master stage.ptySlave = slave - stage.ptyWriter = stage.def.ptyWriter(&stage.stdoutBuf, withCombined, &stage.combinedBuf, shadow) + var flush func() + stage.ptyWriter, flush = stage.def.ptyWriterWithFlush(&stage.stdoutBuf, withCombined, &stage.combinedBuf, shadow) + stage.addFlusher(flush) stage.cmd.Stdout = slave stage.cmd.Stderr = slave } else { - stdoutWriter := stage.def.stdoutWriter(&stage.stdoutBuf, withCombined, &stage.combinedBuf, shadow) - stderrWriter := stage.def.stderrWriter(&stage.stderrBuf, withCombined, &stage.combinedBuf, shadow) - stage.cmd.Stdout = stdoutWriter - stage.cmd.Stderr = stderrWriter + stdoutWriter, stdoutFlush := stage.def.stdoutWriterWithFlush(&stage.stdoutBuf, withCombined, &stage.combinedBuf, shadow) + stderrWriter, stderrFlush := stage.def.stderrWriterWithFlush(&stage.stderrBuf, withCombined, &stage.combinedBuf, shadow) + stage.addFlusher(stdoutFlush) + stage.addFlusher(stderrFlush) + stage.cmd.Stdout = &synchronizedWriter{mu: &pipe.outputMu, writer: stdoutWriter, stage: stage} + stage.cmd.Stderr = &synchronizedWriter{mu: &pipe.outputMu, writer: stderrWriter, stage: stage} } } @@ -63,17 +75,20 @@ func (c *Cmd) newPipeline(withCombined bool, shadow *shadowContext) *pipeline { } reader, writer := io.Pipe() stages[i-1].pipeWriter = writer + stages[i].pipeReader = reader stages[i].cmd.Stdin = reader stages[i-1].cmd.Stdout = io.MultiWriter(stages[i-1].cmd.Stdout, writer) } - return &pipeline{stages: stages, withCombined: withCombined} + return pipe } +// start launches every stage and aborts already-started work if the pipeline cannot be fully constructed. func (p *pipeline) start() { for i, stg := range p.stages { if stg.setupErr != nil { stg.startErr = stg.setupErr + p.abortStart(i, stg.setupErr) break } stg.startErr = stg.cmd.Start() @@ -84,9 +99,7 @@ func (p *pipeline) start() { if stg.ptySlave != nil { _ = stg.ptySlave.Close() } - for j := i + 1; j < len(p.stages); j++ { - p.stages[j].startErr = stg.startErr - } + p.abortStart(i, stg.startErr) break } if stg.ptyMaster != nil { @@ -105,12 +118,35 @@ func (p *pipeline) start() { } } +// abortStart releases pipeline pipes and processes because a partial pipeline cannot make progress safely. +func (p *pipeline) abortStart(failed int, cause error) { + p.startErr = cause + for i := failed + 1; i < len(p.stages); i++ { + p.stages[i].startErr = cause + } + for _, stg := range p.stages { + if stg.pipeReader != nil { + _ = stg.pipeReader.CloseWithError(cause) + } + if stg.pipeWriter != nil { + _ = stg.pipeWriter.CloseWithError(cause) + } + } + for i := 0; i < failed; i++ { + if proc := p.stages[i].cmd.Process; proc != nil { + _ = proc.Kill() + } + } +} + +// wait reaps each process and closes every in-memory pipe once its producer or consumer is done. func (p *pipeline) wait() { for i := range p.stages { if p.stages[i].startErr != nil { if p.stages[i].pipeWriter != nil { _ = p.stages[i].pipeWriter.Close() } + p.stages[i].flush() continue } p.stages[i].waitErr = p.stages[i].cmd.Wait() @@ -118,13 +154,18 @@ func (p *pipeline) wait() { _ = p.stages[i].pipeWriter.Close() } if p.stages[i].ptyDone != nil { - if err := <-p.stages[i].ptyDone; err != nil && p.stages[i].waitErr == nil { - p.stages[i].waitErr = err + if err := <-p.stages[i].ptyDone; err != nil { + p.stages[i].outputErr = err } } + if p.stages[i].pipeReader != nil { + _ = p.stages[i].pipeReader.Close() + } + p.stages[i].flush() } } +// results snapshots stage outcomes after all output and process state has settled. func (p *pipeline) results() []Result { results := make([]Result, 0, len(p.stages)) for _, stage := range p.stages { @@ -133,10 +174,18 @@ func (p *pipeline) results() []Result { return results } +// primaryResult applies the selected pipeline policy without changing per-stage results. func (p *pipeline) primaryResult(mode pipeMode) (Result, string) { results := p.results() primaryIndex := len(results) - 1 - if mode == pipeStrict { + if p.startErr != nil { + for i, res := range results { + if res.Err != nil { + primaryIndex = i + break + } + } + } else if mode == pipeStrict { for i, res := range results { if res.ExitCode != 0 || res.Err != nil { primaryIndex = i @@ -162,6 +211,7 @@ func (p *pipeline) primaryResult(mode pipeMode) (Result, string) { return primary, combined } +// result translates os/exec state while preserving execx's non-zero-exit-is-data contract. func (s *stage) result() Result { res := Result{ Stdout: s.stdoutBuf.String(), @@ -189,9 +239,19 @@ func (s *stage) result() Result { res.ExitCode = s.cmd.ProcessState.ExitCode() res.signal = signalFromState(s.cmd.ProcessState) } + if res.Err == nil && s.outputErr != nil { + res.Err = ErrExec{Err: s.outputErr, ExitCode: res.ExitCode, Signal: res.signal, Stderr: res.Stderr} + } + if res.Err == nil && s.waitErr != nil { + var exitErr *exec.ExitError + if !errors.As(s.waitErr, &exitErr) { + res.Err = ErrExec{Err: s.waitErr, ExitCode: res.ExitCode, Signal: res.signal, Stderr: res.Stderr} + } + } return res } +// pipelineStages walks from the root because fluent calls may execute from any stage in a chain. func (c *Cmd) pipelineStages() []*stage { root := c.rootCmd() stages := []*stage{} @@ -200,3 +260,56 @@ func (c *Cmd) pipelineStages() []*stage { } return stages } + +// addFlusher retains only callbacks that have buffered-line state to drain. +func (s *stage) addFlusher(flush func()) { + if flush != nil { + s.flushOutput = append(s.flushOutput, flush) + } +} + +// flush delivers unterminated callback lines only after their output stream is closed. +func (s *stage) flush() { + for _, flush := range s.flushOutput { + flush() + } + s.flushOutput = nil +} + +// synchronizedWriter serializes callbacks and caller-provided writers across an entire pipeline. +type synchronizedWriter struct { + mu *sync.Mutex + writer io.Writer + stage *stage +} + +// Write preserves stream chunks while preventing concurrent callback or writer invocation. +func (w *synchronizedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + n, err := w.writer.Write(p) + if err != nil && w.stage.outputErr == nil { + w.stage.outputErr = err + } + return n, err +} + +// synchronizedBuffer retains the relative order of concurrently arriving stdout and stderr chunks. +type synchronizedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +// Write appends one complete stream chunk under the combined-output lock. +func (b *synchronizedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +// String returns a detached combined-output string after execution has completed. +func (b *synchronizedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} diff --git a/pty_darwin.go b/pty_darwin.go index 2376ad8..558dcc1 100644 --- a/pty_darwin.go +++ b/pty_darwin.go @@ -9,14 +9,17 @@ import ( "unsafe" ) +// ptyCheck reports Darwin support before command setup allocates any descriptors. func ptyCheck() error { return nil } +// openPTY delegates system calls so error paths remain deterministic in tests. func openPTY() (*os.File, *os.File, error) { return openPTYWith(os.OpenFile, ptyIoctl) } +// openPTYWith grants and unlocks a Darwin PTY before opening its discovered slave device. func openPTYWith(openFile func(string, int, os.FileMode) (*os.File, error), ioctl func(uintptr, uintptr, uintptr) error) (*os.File, *os.File, error) { master, err := openFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) if err != nil { @@ -44,6 +47,7 @@ func openPTYWith(openFile func(string, int, os.FileMode) (*os.File, error), ioct return master, slave, nil } +// ptyIoctl converts the raw syscall errno into an ordinary Go error. func ptyIoctl(fd uintptr, req uintptr, arg uintptr) error { _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, req, arg) if errno != 0 { diff --git a/pty_darwin_test.go b/pty_darwin_test.go index f3aadb6..19babdb 100644 --- a/pty_darwin_test.go +++ b/pty_darwin_test.go @@ -7,9 +7,9 @@ import ( "os" "syscall" "testing" - "unsafe" ) +// TestPTYDarwinOpen ensures the host kernel can provide the master and slave pair used by PTY execution. func TestPTYDarwinOpen(t *testing.T) { if err := ptyCheck(); err != nil { t.Fatalf("unexpected pty check error: %v", err) @@ -22,6 +22,7 @@ func TestPTYDarwinOpen(t *testing.T) { _ = slave.Close() } +// TestPTYIoctlSuccessAndError ensures Darwin ioctl results preserve both success and kernel failures. func TestPTYIoctlSuccessAndError(t *testing.T) { master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) if err != nil { @@ -36,6 +37,7 @@ func TestPTYIoctlSuccessAndError(t *testing.T) { } } +// TestOpenPTYWithOpenError ensures opening the multiplexer is the first reported PTY failure. func TestOpenPTYWithOpenError(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open failed") @@ -46,6 +48,7 @@ func TestOpenPTYWithOpenError(t *testing.T) { } } +// TestOpenPTYWithGrantError ensures permission-grant failures cannot yield a partial PTY pair. func TestOpenPTYWithGrantError(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -61,6 +64,7 @@ func TestOpenPTYWithGrantError(t *testing.T) { } } +// TestOpenPTYWithUnlockError ensures a locked slave cannot escape as a partially initialized pair. func TestOpenPTYWithUnlockError(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -77,6 +81,7 @@ func TestOpenPTYWithUnlockError(t *testing.T) { } } +// TestOpenPTYWithNameError ensures slave-name lookup failures abort PTY initialization. func TestOpenPTYWithNameError(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -93,6 +98,7 @@ func TestOpenPTYWithNameError(t *testing.T) { } } +// TestOpenPTYWithSlaveError ensures slave-open failures are returned after master initialization. func TestOpenPTYWithSlaveError(t *testing.T) { openFile := func(name string, flag int, perm os.FileMode) (*os.File, error) { if name == "/dev/ptmx" { @@ -100,30 +106,19 @@ func TestOpenPTYWithSlaveError(t *testing.T) { } return nil, errors.New("slave open failed") } - ioctl := func(fd uintptr, req uintptr, arg uintptr) error { - if req == syscall.TIOCPTYGNAME { - buf := (*[128]byte)(unsafe.Pointer(arg)) - copy(buf[:], []byte("/dev/doesnotexist")) - } - return nil - } + ioctl := func(uintptr, uintptr, uintptr) error { return nil } _, _, err := openPTYWith(openFile, ioctl) if err == nil || err.Error() != "slave open failed" { t.Fatalf("expected slave open error, got %v", err) } } +// TestOpenPTYWithSuccess ensures injected Darwin system calls can complete a usable PTY pair. func TestOpenPTYWithSuccess(t *testing.T) { openFile := func(name string, flag int, perm os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) } - ioctl := func(fd uintptr, req uintptr, arg uintptr) error { - if req == syscall.TIOCPTYGNAME { - buf := (*[128]byte)(unsafe.Pointer(arg)) - copy(buf[:], []byte(os.DevNull)) - } - return nil - } + ioctl := func(uintptr, uintptr, uintptr) error { return nil } master, slave, err := openPTYWith(openFile, ioctl) if err != nil { t.Fatalf("expected success, got %v", err) diff --git a/pty_linux.go b/pty_linux.go index e774bf0..c3fde1e 100644 --- a/pty_linux.go +++ b/pty_linux.go @@ -9,14 +9,17 @@ import ( "unsafe" ) +// ptyCheck reports Linux support before command setup allocates any descriptors. func ptyCheck() error { return nil } +// openPTY delegates system calls so error paths remain deterministic in tests. func openPTY() (*os.File, *os.File, error) { return openPTYWith(os.OpenFile, ptyIoctl) } +// openPTYWith unlocks a Unix98 PTY master and opens its discovered slave device. func openPTYWith(openFile func(string, int, os.FileMode) (*os.File, error), ioctl func(uintptr, uintptr, uintptr) error) (*os.File, *os.File, error) { master, err := openFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) if err != nil { @@ -42,6 +45,7 @@ func openPTYWith(openFile func(string, int, os.FileMode) (*os.File, error), ioct return master, slave, nil } +// ptyIoctl converts the raw syscall errno into an ordinary Go error. func ptyIoctl(fd uintptr, req uintptr, arg uintptr) error { _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, req, arg) if errno != 0 { diff --git a/pty_linux_test.go b/pty_linux_test.go index 6d5fdce..0710d60 100644 --- a/pty_linux_test.go +++ b/pty_linux_test.go @@ -7,9 +7,9 @@ import ( "os" "syscall" "testing" - "unsafe" ) +// TestPTYLinuxOpen ensures the host kernel can provide the master and slave pair used by PTY execution. func TestPTYLinuxOpen(t *testing.T) { if err := ptyCheck(); err != nil { t.Fatalf("unexpected pty check error: %v", err) @@ -22,21 +22,14 @@ func TestPTYLinuxOpen(t *testing.T) { _ = slave.Close() } +// TestPTYIoctlSuccessAndErrorLinux ensures ioctl failures are surfaced instead of silently ignored. func TestPTYIoctlSuccessAndErrorLinux(t *testing.T) { - master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0) - if err != nil { - t.Fatalf("open ptmx: %v", err) - } - defer master.Close() - unlock := int32(0) - if err := ptyIoctl(master.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != nil { - t.Fatalf("expected ioctl success, got %v", err) - } if err := ptyIoctl(0, 0, 0); err == nil { t.Fatalf("expected ioctl error") } } +// TestOpenPTYWithOpenErrorLinux ensures opening the multiplexer is the first reported PTY failure. func TestOpenPTYWithOpenErrorLinux(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open failed") @@ -47,6 +40,7 @@ func TestOpenPTYWithOpenErrorLinux(t *testing.T) { } } +// TestOpenPTYWithUnlockErrorLinux ensures a locked slave cannot escape as a partially initialized pair. func TestOpenPTYWithUnlockErrorLinux(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -62,6 +56,7 @@ func TestOpenPTYWithUnlockErrorLinux(t *testing.T) { } } +// TestOpenPTYWithPTNErrorLinux ensures slave-number lookup failures close the incomplete PTY setup. func TestOpenPTYWithPTNErrorLinux(t *testing.T) { openFile := func(string, int, os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -77,6 +72,7 @@ func TestOpenPTYWithPTNErrorLinux(t *testing.T) { } } +// TestOpenPTYWithSlaveErrorLinux ensures slave-open failures are returned after master initialization. func TestOpenPTYWithSlaveErrorLinux(t *testing.T) { openFile := func(name string, flag int, perm os.FileMode) (*os.File, error) { if name == "/dev/ptmx" { @@ -84,28 +80,19 @@ func TestOpenPTYWithSlaveErrorLinux(t *testing.T) { } return nil, errors.New("slave open failed") } - ioctl := func(fd uintptr, req uintptr, arg uintptr) error { - if req == syscall.TIOCGPTN { - *(*uint32)(unsafe.Pointer(arg)) = 1234 - } - return nil - } + ioctl := func(uintptr, uintptr, uintptr) error { return nil } _, _, err := openPTYWith(openFile, ioctl) if err == nil || err.Error() != "slave open failed" { t.Fatalf("expected slave open error, got %v", err) } } +// TestOpenPTYWithSuccessLinux ensures injected system calls can complete a usable PTY pair. func TestOpenPTYWithSuccessLinux(t *testing.T) { openFile := func(name string, flag int, perm os.FileMode) (*os.File, error) { return os.OpenFile(os.DevNull, os.O_RDWR, 0) } - ioctl := func(fd uintptr, req uintptr, arg uintptr) error { - if req == syscall.TIOCGPTN { - *(*uint32)(unsafe.Pointer(arg)) = 0 - } - return nil - } + ioctl := func(uintptr, uintptr, uintptr) error { return nil } master, slave, err := openPTYWith(openFile, ioctl) if err != nil { t.Fatalf("expected success, got %v", err) diff --git a/pty_unsupported.go b/pty_unsupported.go index 7f66181..67a716e 100644 --- a/pty_unsupported.go +++ b/pty_unsupported.go @@ -7,10 +7,12 @@ import ( "os" ) +// ptyCheck keeps unsupported-platform failure deterministic and side-effect free. func ptyCheck() error { return errors.New("execx: WithPTY is not supported on this platform") } +// openPTY mirrors ptyCheck for callers that reach allocation directly. func openPTY() (*os.File, *os.File, error) { return nil, nil, ptyCheck() } diff --git a/result.go b/result.go index 500ba7c..1a5c3f8 100644 --- a/result.go +++ b/result.go @@ -7,10 +7,15 @@ import ( // Result captures the outcome of a command execution. type Result struct { - Stdout string - Stderr string + // Stdout contains output captured from the selected command or pipeline stage. + Stdout string + // Stderr contains error output captured separately from stdout. + Stderr string + // ExitCode contains the process exit status, or -1 when no process state exists. ExitCode int - Err error + // Err mirrors the execution error returned by Run, Wait, or their variants. + Err error + // Duration measures elapsed time from pipeline construction through process completion. Duration time.Duration signal os.Signal diff --git a/signal_other.go b/signal_other.go new file mode 100644 index 0000000..377d345 --- /dev/null +++ b/signal_other.go @@ -0,0 +1,10 @@ +//go:build !unix && !windows + +package execx + +import "os" + +// signalFromState returns nil because these platforms expose no portable process-signal mapping. +func signalFromState(_ *os.ProcessState) os.Signal { + return nil +} diff --git a/signal_unix.go b/signal_unix.go index 8009a8d..c2302e8 100644 --- a/signal_unix.go +++ b/signal_unix.go @@ -7,6 +7,7 @@ import ( "syscall" ) +// signalFromState exposes signal termination without treating it as an execution error. func signalFromState(state *os.ProcessState) os.Signal { if state == nil { return nil diff --git a/signal_windows.go b/signal_windows.go index f390152..b91c50d 100644 --- a/signal_windows.go +++ b/signal_windows.go @@ -4,6 +4,7 @@ package execx import "os" +// signalFromState returns nil because Windows ProcessState has no portable os.Signal mapping. func signalFromState(_ *os.ProcessState) os.Signal { return nil } diff --git a/sysproc_linux.go b/sysproc_linux.go index bd172bf..2bb3ad3 100644 --- a/sysproc_linux.go +++ b/sysproc_linux.go @@ -46,6 +46,7 @@ func (c *Cmd) Pdeathsig(sig syscall.Signal) *Cmd { return c } +// ensureSysProcAttr allocates Linux process controls only when a caller opts into them. func (c *Cmd) ensureSysProcAttr() { if c.sysProcAttr == nil { c.sysProcAttr = &syscall.SysProcAttr{} diff --git a/sysproc_nonlinux_test.go b/sysproc_nonlinux_test.go index a7c026f..b9e96c2 100644 --- a/sysproc_nonlinux_test.go +++ b/sysproc_nonlinux_test.go @@ -7,6 +7,7 @@ import ( "testing" ) +// TestPdeathsigNoop ensures the Linux-only parent-death signal API remains harmless on other Unix systems. func TestPdeathsigNoop(t *testing.T) { cmd := Command("echo") cmd.Pdeathsig(syscall.SIGTERM) diff --git a/sysproc_other.go b/sysproc_other.go new file mode 100644 index 0000000..edd464e --- /dev/null +++ b/sysproc_other.go @@ -0,0 +1,23 @@ +//go:build !unix && !windows && !plan9 + +package execx + +import "syscall" + +// Setpgid is a no-op on platforms without Unix process groups. +// @group OS Controls +func (c *Cmd) Setpgid(_ bool) *Cmd { + return c +} + +// Setsid is a no-op on platforms without Unix sessions. +// @group OS Controls +func (c *Cmd) Setsid(_ bool) *Cmd { + return c +} + +// Pdeathsig is a no-op outside Linux because parent-death signals are Linux-specific. +// @group OS Controls +func (c *Cmd) Pdeathsig(_ syscall.Signal) *Cmd { + return c +} diff --git a/sysproc_plan9.go b/sysproc_plan9.go new file mode 100644 index 0000000..40ed5ad --- /dev/null +++ b/sysproc_plan9.go @@ -0,0 +1,23 @@ +//go:build plan9 + +package execx + +import "syscall" + +// Setpgid is a no-op because Plan 9 does not expose Unix process groups. +// @group OS Controls +func (c *Cmd) Setpgid(_ bool) *Cmd { + return c +} + +// Setsid is a no-op because Plan 9 does not expose Unix sessions. +// @group OS Controls +func (c *Cmd) Setsid(_ bool) *Cmd { + return c +} + +// Pdeathsig is a no-op because parent-death signals are Linux-specific. +// @group OS Controls +func (c *Cmd) Pdeathsig(_ syscall.Note) *Cmd { + return c +} diff --git a/sysproc_unix.go b/sysproc_unix.go index 15befab..e35d35e 100644 --- a/sysproc_unix.go +++ b/sysproc_unix.go @@ -26,6 +26,7 @@ func (c *Cmd) Pdeathsig(_ syscall.Signal) *Cmd { return c } +// ensureSysProcAttr allocates Unix process controls only when a caller opts into them. func (c *Cmd) ensureSysProcAttr() { if c.sysProcAttr == nil { c.sysProcAttr = &syscall.SysProcAttr{} diff --git a/sysproc_unix_test.go b/sysproc_unix_test.go index 21f58c7..b8df6bd 100644 --- a/sysproc_unix_test.go +++ b/sysproc_unix_test.go @@ -4,6 +4,7 @@ package execx import "testing" +// TestSysProcAttrUnixFlags ensures Unix process-group and session settings reach os/exec unchanged. func TestSysProcAttrUnixFlags(t *testing.T) { cmd := Command("echo") cmd.Setpgid(true).Setsid(true) diff --git a/sysproc_windows.go b/sysproc_windows.go index 1c4fa54..e1d92ba 100644 --- a/sysproc_windows.go +++ b/sysproc_windows.go @@ -50,11 +50,12 @@ func (c *Cmd) HideWindow(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.HideWindow = on if on { - c.sysProcAttr.CreationFlags |= syscall.CREATE_NO_WINDOW + c.sysProcAttr.CreationFlags |= CreateNoWindow } return c } +// ensureSysProcAttr allocates Windows process controls only when a caller opts into them. func (c *Cmd) ensureSysProcAttr() { if c.sysProcAttr == nil { c.sysProcAttr = &syscall.SysProcAttr{} diff --git a/sysproc_windows_test.go b/sysproc_windows_test.go index 13b7bd8..c6d8bd0 100644 --- a/sysproc_windows_test.go +++ b/sysproc_windows_test.go @@ -2,11 +2,9 @@ package execx -import ( - "syscall" - "testing" -) +import "testing" +// TestHideWindowSetsCreateNoWindow ensures window suppression preserves caller-supplied creation flags. func TestHideWindowSetsCreateNoWindow(t *testing.T) { cmd := Command("go", "env", "GOOS") cmd.CreationFlags(0x10).HideWindow(true) @@ -16,7 +14,7 @@ func TestHideWindowSetsCreateNoWindow(t *testing.T) { if !cmd.sysProcAttr.HideWindow { t.Fatalf("expected HideWindow set") } - if cmd.sysProcAttr.CreationFlags&syscall.CREATE_NO_WINDOW == 0 { + if cmd.sysProcAttr.CreationFlags&CreateNoWindow == 0 { t.Fatalf("expected CREATE_NO_WINDOW flag") } if cmd.sysProcAttr.CreationFlags&0x10 == 0 { diff --git a/testsignal_other_test.go b/testsignal_other_test.go new file mode 100644 index 0000000..a5613af --- /dev/null +++ b/testsignal_other_test.go @@ -0,0 +1,10 @@ +//go:build !unix && !windows + +package execx + +import "os" + +// terminateHelperProcess uses an exit code where no portable self-signal mechanism exists. +func terminateHelperProcess() { + os.Exit(3) +} diff --git a/testsignal_unix_test.go b/testsignal_unix_test.go new file mode 100644 index 0000000..6b32532 --- /dev/null +++ b/testsignal_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package execx + +import ( + "os" + "syscall" +) + +// terminateHelperProcess delivers a real signal so Unix result mapping is exercised end to end. +func terminateHelperProcess() { + _ = syscall.Kill(os.Getpid(), syscall.SIGTERM) +} diff --git a/testsignal_windows_test.go b/testsignal_windows_test.go new file mode 100644 index 0000000..d36e024 --- /dev/null +++ b/testsignal_windows_test.go @@ -0,0 +1,10 @@ +//go:build windows + +package execx + +import "os" + +// terminateHelperProcess uses an exit code because Windows has no portable self-signal equivalent. +func terminateHelperProcess() { + os.Exit(3) +}