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 @@
-
+
-
+
@@ -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)
+}