diff --git a/exec/exec.go b/exec/exec.go index 6a2c49e..00b1d69 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -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") diff --git a/exec/exec_test.go b/exec/exec_test.go index 6e2544b..f7d8cb9 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -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")