Compare commits

..

No commits in common. "cmd-and-timeout" and "main" have entirely different histories.

2 changed files with 21 additions and 118 deletions

58
main.go
View File

@ -32,7 +32,6 @@ type ShellRunner struct {
mu sync.Mutex
isStopped bool
activeCmds map[*exec.Cmd]struct{} // Track active commands for cancellation
timeout time.Duration
}
// NewShellRunner creates a new ShellRunner instance with the default shell (`sh`).
@ -50,14 +49,9 @@ func NewShellRunnerWithShell(shell string) *ShellRunner {
shell: shell,
activeCmds: make(map[*exec.Cmd]struct{}),
isStopped: true,
timeout: 0,
}
}
func (sr *ShellRunner) SetTimeout(timeout time.Duration) {
sr.timeout = timeout
}
// Start begins processing shell commands asynchronously.
func (sr *ShellRunner) Start() {
go func() {
@ -89,17 +83,8 @@ func (sr *ShellRunner) Start() {
// AddCommand adds a shell command to be executed with an optional callback.
// No commands can be added if the runner has been stopped or not yet started.
// The callback is executed asynchronously after the command has completed.
// The order of command execution and callback invocation can be expected to be preserved (maybe?).
// The order of command execution and callback invocation can be expected to be preserved.
func (sr *ShellRunner) AddCommand(command string, callback func(*CommandResult)) error {
cmd, cancel := sr.newShellCommand(command)
return sr.AddCmd(cmd, callback, cancel)
}
// AddCmd adds a pre-configured exec.Cmd to be executed with an optional callback and cancel function.
// No commands can be added if the runner has been stopped or not yet started.
// The callback is executed asynchronously after the command has completed.
// The order of command execution and callback invocation can be expected to be preserved (maybe?).
func (sr *ShellRunner) AddCmd(cmd *exec.Cmd, callback func(*CommandResult), cancel context.CancelFunc) error {
sr.mu.Lock()
defer sr.mu.Unlock()
@ -108,12 +93,10 @@ func (sr *ShellRunner) AddCmd(cmd *exec.Cmd, callback func(*CommandResult), canc
}
sr.cmdQueue <- func() *CommandResult {
result := sr.executeCommand(cmd, cancel)
result := sr.executeCommand(command)
if callback != nil {
sr.running.Add(1)
sr.callbacks <- func() {
callback(result)
sr.running.Done()
}
}
return result
@ -152,6 +135,15 @@ func (sr *ShellRunner) Stop() {
// Kill stops the ShellRunner immediately, terminating all running commands.
func (sr *ShellRunner) Kill() {
sr.mu.Lock()
if sr.isStopped {
sr.mu.Unlock()
return
}
sr.isStopped = true
close(sr.cmdQueue) // Prevent further commands
close(sr.stopChan)
// Terminate all active commands
for cmd := range sr.activeCmds {
@ -159,7 +151,7 @@ func (sr *ShellRunner) Kill() {
}
sr.mu.Unlock()
sr.Stop()
sr.running.Wait()
}
// KillWithTimeout attempts to stop the ShellRunner, killing commands if the duration is exceeded.
@ -179,28 +171,12 @@ func (sr *ShellRunner) KillWithTimeout(timeout time.Duration) error {
}
}
func (sr *ShellRunner) newShellCommand(command string) (*exec.Cmd, context.CancelFunc) {
var ctx context.Context
var cancel context.CancelFunc
if sr.timeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), sr.timeout)
} else {
ctx = context.Background()
}
return exec.CommandContext(ctx, sr.shell, "-c", command), cancel
}
// executeCommand runs a shell command asynchronously, capturing stdout, stderr, and return code.
func (sr *ShellRunner) executeCommand(cmd *exec.Cmd, cancel context.CancelFunc) *CommandResult {
if cancel != nil {
defer cancel()
}
func (sr *ShellRunner) executeCommand(command string) *CommandResult {
var outBuf, errBuf bytes.Buffer
ctx := context.Background()
cmd := exec.CommandContext(ctx, sr.shell, "-c", command)
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
@ -212,7 +188,7 @@ func (sr *ShellRunner) executeCommand(cmd *exec.Cmd, cancel context.CancelFunc)
err := cmd.Start()
if err != nil {
return &CommandResult{
Command: cmd.String(),
Command: command,
ReturnCode: -1,
ErrOutput: err.Error(),
}
@ -226,7 +202,7 @@ func (sr *ShellRunner) executeCommand(cmd *exec.Cmd, cancel context.CancelFunc)
sr.mu.Unlock()
result := &CommandResult{
Command: cmd.String(),
Command: command,
Output: outBuf.String(),
ErrOutput: errBuf.String(),
}

View File

@ -1,8 +1,6 @@
package tortoise_test
import (
"context"
"os/exec"
"sync"
"testing"
"time"
@ -10,10 +8,6 @@ import (
"git.iamthefij.com/iamthefij/tortoise"
)
const (
TaskStartWait = 10 * time.Millisecond
)
func TestShellRunnerNoCallback(t *testing.T) {
t.Parallel()
@ -39,17 +33,10 @@ func TestShellRunnerNoCallback(t *testing.T) {
t.Fatalf("unexpected error adding command: %v", err)
}
// Wait a sec for the worker to pick up the task
time.Sleep(TaskStartWait)
runner.Stop()
result := runner.GetResults()
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Output != c.output || result.ReturnCode != c.ReturnCode {
if result == nil || result.Output != c.output || result.ReturnCode != c.ReturnCode {
t.Fatalf("expected output '%s' and return code %d, got '%s' and %d", c.output, c.ReturnCode, result.Output, result.ReturnCode)
}
})
@ -90,9 +77,6 @@ func TestShellRunnerCallback(t *testing.T) {
t.Fatalf("unexpected error adding command: %v", err)
}
// Wait a sec for the worker to pick up the task
time.Sleep(TaskStartWait)
// Timeout waiting for callbacks
done := make(chan struct{})
go func() {
@ -107,7 +91,7 @@ func TestShellRunnerCallback(t *testing.T) {
}
if outputString != "ab" {
t.Fatal("callbacks were not reached in order:", outputString)
t.Fatal("callbacks was not reached in order:", outputString)
}
runner.Stop()
@ -135,8 +119,8 @@ func TestShellRunnerKillWithTimeout(t *testing.T) {
t.Fatalf("unexpected error adding command: %v", err)
}
// Wait a sec for the worker to pick up the task
time.Sleep(TaskStartWait)
// Wait one second to make sure the command starts running
time.Sleep(1 * time.Second)
if err := runner.KillWithTimeout(1 * time.Second); err == nil {
t.Fatal("expected error when killing commands, but got none")
@ -167,60 +151,3 @@ func TestAddingPriorToStart(t *testing.T) {
t.Fatal("Should have failed to add prior to starting runner")
}
}
func TestAddCmdWithTimeout(t *testing.T) {
t.Parallel()
runner := tortoise.NewShellRunner()
runner.Start()
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
cmd := exec.CommandContext(ctx, "sleep", "10")
err := runner.AddCmd(cmd, nil, cancel)
if err != nil {
t.Fatalf("unexpected error adding command: %v", err)
}
// Wait a sec for the worker to pick up the task
time.Sleep(TaskStartWait)
runner.Stop()
result := runner.GetResults()
if result == nil {
t.Fatal("expected result, got nil")
}
if result.ReturnCode != -1 {
t.Fatalf("expected return code -1, got %d", result.ReturnCode)
}
}
func TestShellWithTimeout(t *testing.T) {
t.Parallel()
runner := tortoise.NewShellRunner()
runner.SetTimeout(1 * time.Second)
runner.Start()
err := runner.AddCommand("sleep 10", nil)
if err != nil {
t.Fatalf("unexpected error adding command: %v", err)
}
// Wait a sec for the worker to pick up the task
time.Sleep(TaskStartWait)
runner.Stop()
result := runner.GetResults()
if result == nil {
t.Fatal("expected result, got nil")
}
if result.ReturnCode != -1 {
t.Fatalf("expected return code -1, got %d", result.ReturnCode)
}
}