feat(exec): add background command support

This commit is contained in:
Virgil 2026-04-03 23:27:27 +00:00
parent 62e7bd7814
commit 87bebd7fa6
2 changed files with 87 additions and 2 deletions

View file

@ -19,8 +19,8 @@ type Options struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// If true, command will run in background (not implemented in this wrapper yet)
// Background bool
// Background runs the command asynchronously and returns from Run immediately.
Background bool
}
// Command wraps os/exec.Command with logging and context
@ -79,9 +79,39 @@ func (c *Cmd) WithLogger(l Logger) *Cmd {
return c
}
// WithBackground configures whether Run should wait for the command to finish.
func (c *Cmd) WithBackground(background bool) *Cmd {
c.opts.Background = background
return c
}
// Start launches the command.
func (c *Cmd) Start() error {
c.prepare()
c.logDebug("executing command")
if err := c.cmd.Start(); err != nil {
wrapped := wrapError("Cmd.Start", err, c.name, c.args)
c.logError("command failed", wrapped)
return wrapped
}
if c.opts.Background {
go func(cmd *exec.Cmd) {
_ = cmd.Wait()
}(c.cmd)
}
return nil
}
// Run executes the command and waits for it to finish.
// It automatically logs the command execution at debug level.
func (c *Cmd) Run() error {
if c.opts.Background {
return c.Start()
}
c.prepare()
c.logDebug("executing command")
@ -95,6 +125,10 @@ func (c *Cmd) Run() error {
// Output runs the command and returns its standard output.
func (c *Cmd) Output() ([]byte, error) {
if c.opts.Background {
return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil)
}
c.prepare()
c.logDebug("executing command")
@ -109,6 +143,10 @@ func (c *Cmd) Output() ([]byte, error) {
// CombinedOutput runs the command and returns its combined standard output and standard error.
func (c *Cmd) CombinedOutput() ([]byte, error) {
if c.opts.Background {
return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil)
}
c.prepare()
c.logDebug("executing command")

View file

@ -2,8 +2,12 @@ package exec_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"dappco.re/go/core/process/exec"
)
@ -195,6 +199,49 @@ func TestCommand_WithStdinStdoutStderr(t *testing.T) {
}
}
func TestCommand_Run_Background(t *testing.T) {
logger := &mockLogger{}
ctx := context.Background()
dir := t.TempDir()
marker := filepath.Join(dir, "marker.txt")
start := time.Now()
err := exec.Command(ctx, "sh", "-c", fmt.Sprintf("sleep 0.2; printf done > %q", marker)).
WithBackground(true).
WithLogger(logger).
Run()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("background run took too long: %s", elapsed)
}
deadline := time.Now().Add(2 * time.Second)
for {
data, readErr := os.ReadFile(marker)
if readErr == nil && strings.TrimSpace(string(data)) == "done" {
break
}
if time.Now().After(deadline) {
t.Fatalf("background command did not create marker file")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestCommand_Output_BackgroundRejected(t *testing.T) {
ctx := context.Background()
_, err := exec.Command(ctx, "echo", "test").
WithBackground(true).
Output()
if err == nil {
t.Fatal("expected error")
}
}
func TestRunQuiet_Good(t *testing.T) {
ctx := context.Background()
err := exec.RunQuiet(ctx, "echo", "quiet")