Initial commit

This commit is contained in:
IamTheFij 2024-10-21 12:29:17 -07:00
commit fa450dd130
9 changed files with 461 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# ---> Go
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
.DS_Store
coverage.out
dist/

36
.golangci.yml Normal file
View File

@ -0,0 +1,36 @@
---
linters:
enable:
- errname
- errorlint
- exhaustive
- gofumpt
- goimports
- gomnd
- goprintffuncname
- misspell
- tagliatelle
- tenv
- testpackage
- thelper
- tparallel
- unconvert
- wrapcheck
- wsl
disable:
- gochecknoglobals
linters-settings:
gosec:
excludes:
- G204
tagliatelle:
case:
rules:
yaml: snake
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec

16
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,16 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: check-added-large-files
- id: check-yaml
args:
- --allow-multiple-documents
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- repo: https://github.com/golangci/golangci-lint
rev: v1.52.2
hooks:
- id: golangci-lint

8
LICENSE Normal file
View File

@ -0,0 +1,8 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
GOFILES = *.go go.mod
# Default make target will run tests
.DEFAULT_GOAL = test
# Run all tests
.PHONY: test
test:
go test -coverprofile=coverage.out
go tool cover -func=coverage.out
@go tool cover -func=coverage.out | awk -v target=50.0% \
'/^total:/ { print "Total coverage: " $$3 " Minimum coverage: " target; if ($$3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }'
# Installs pre-commit hooks
.PHONY: install-hooks
install-hooks:
pre-commit install --install-hooks
# Runs pre-commit checks on files
.PHONY: check
check:
pre-commit run --all-files
.PHONY: clean
clean:
rm -f ./coverage.out
rm -fr ./dist

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# Go Shell Runner
Library for asyncronously executing shell commands in Go

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module git.iamthefij.com/iamthefij/go-shell-runner
go 1.21.4

217
main.go Normal file
View File

@ -0,0 +1,217 @@
package shellrunner
import (
"bytes"
"context"
"errors"
"os/exec"
"sync"
"time"
)
const (
MAX_RESULTS = 100
)
// CommandResult holds the result of a shell command execution.
type CommandResult struct {
Command string
ReturnCode int
Output string
ErrOutput string
}
// ShellRunner manages shell command execution.
type ShellRunner struct {
cmdQueue chan func() *CommandResult
results chan *CommandResult
stopChan chan struct{}
running sync.WaitGroup
callbacks chan func()
shell string // Private field to store the shell
mu sync.Mutex
isStopped bool
activeCmds map[*exec.Cmd]struct{} // Track active commands for cancellation
}
// NewShellRunner creates a new ShellRunner instance with the default shell (`sh`).
func NewShellRunner() *ShellRunner {
return NewShellRunnerWithShell("sh")
}
// NewShellRunnerWithShell creates a new ShellRunner with the specified shell.
func NewShellRunnerWithShell(shell string) *ShellRunner {
return &ShellRunner{
cmdQueue: make(chan func() *CommandResult),
results: make(chan *CommandResult, MAX_RESULTS),
stopChan: make(chan struct{}),
callbacks: make(chan func(), MAX_RESULTS),
shell: shell,
activeCmds: make(map[*exec.Cmd]struct{}),
}
}
// Start begins processing shell commands asynchronously.
func (sr *ShellRunner) Start() {
go func() {
for {
select {
case <-sr.stopChan:
return
case cmdFunc, ok := <-sr.cmdQueue:
if !ok {
return
}
sr.running.Add(1)
go func() {
result := cmdFunc()
sr.results <- result
sr.running.Done()
}()
case callback := <-sr.callbacks:
callback()
}
}
}()
}
// AddCommand adds a shell command to be executed with an optional callback.
// No commands can be added if the runner has been stopped.
func (sr *ShellRunner) AddCommand(command string, callback func(*CommandResult)) error {
sr.mu.Lock()
defer sr.mu.Unlock()
if sr.isStopped {
return errors.New("runner has been stopped, cannot add new commands")
}
sr.cmdQueue <- func() *CommandResult {
result := sr.executeCommand(command)
if callback != nil {
sr.callbacks <- func() {
callback(result)
}
}
return result
}
return nil
}
// GetResults retrieves the next available command result (non-blocking).
func (sr *ShellRunner) GetResults() *CommandResult {
select {
case result := <-sr.results:
return result
default:
return nil
}
}
// Stop gracefully stops the ShellRunner, closing the command queue and waiting for all commands to finish.
func (sr *ShellRunner) Stop() {
sr.mu.Lock()
if sr.isStopped {
sr.mu.Unlock()
return
}
sr.isStopped = true
close(sr.cmdQueue) // No more commands can be added
close(sr.stopChan)
sr.mu.Unlock()
sr.running.Wait()
}
// 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 {
_ = cmd.Process.Kill()
}
sr.mu.Unlock()
sr.running.Wait()
}
// KillWithTimeout attempts to stop the ShellRunner, killing commands if the duration is exceeded.
func (sr *ShellRunner) KillWithTimeout(timeout time.Duration) error {
done := make(chan struct{})
go func() {
sr.Stop()
close(done)
}()
select {
case <-done:
return nil
case <-time.After(timeout):
sr.Kill()
return errors.New("commands killed due to timeout")
}
}
// executeCommand runs a shell command asynchronously, capturing stdout, stderr, and return code.
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
// Track the active command
sr.mu.Lock()
sr.activeCmds[cmd] = struct{}{}
sr.mu.Unlock()
err := cmd.Start()
if err != nil {
return &CommandResult{
Command: command,
ReturnCode: -1,
ErrOutput: err.Error(),
}
}
err = cmd.Wait()
// Remove from active commands
sr.mu.Lock()
delete(sr.activeCmds, cmd)
sr.mu.Unlock()
result := &CommandResult{
Command: command,
Output: outBuf.String(),
ErrOutput: errBuf.String(),
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
result.ReturnCode = exitErr.ExitCode()
} else {
result.ReturnCode = -1
}
} else {
result.ReturnCode = 0
}
return result
}

122
main_test.go Normal file
View File

@ -0,0 +1,122 @@
package shellrunner_test
import (
"testing"
"time"
shellrunner "git.iamthefij.com/iamthefij/go-shell-runner"
)
func TestShellRunnerNoCallback(t *testing.T) {
cases := []struct {
command string
output string
ReturnCode int
}{
{"echo hello world", "hello world\n", 0},
{"echo hello world && exit 1", "hello world\n", 1},
}
for _, c := range cases {
c := c
t.Run(c.command, func(t *testing.T) {
t.Parallel()
runner := shellrunner.NewShellRunner()
runner.Start()
// Test command without callback
if err := runner.AddCommand(c.command, nil); err != nil {
t.Fatalf("unexpected error adding command: %v", err)
}
runner.Stop()
result := runner.GetResults()
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)
}
})
}
}
func TestShellRunnerCallback(t *testing.T) {
t.Parallel()
runner := shellrunner.NewShellRunner()
runner.Start()
// Test command with callback
done := make(chan struct{})
callbackReached := false
if err := runner.AddCommand("echo callback test", func(result *shellrunner.CommandResult) {
callbackReached = true
if result.Output != "callback test\n" {
t.Fatalf("expected 'callback test', got '%s'", result.Output)
}
close(done)
}); err != nil {
t.Fatalf("unexpected error adding command: %v", err)
}
// Timeout waiting for callback
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("callback timed out")
}
if !callbackReached {
t.Fatal("callback was not reached")
}
runner.Stop()
// Make sure stop and kill both exit gracefully after the runner is stopped
runner.Stop()
runner.Stop()
}
func TestShellRunnerKillWithTimeout(t *testing.T) {
t.Parallel()
runner := shellrunner.NewShellRunner()
runner.Start()
// Test command with callback
callbackReached := false
if err := runner.AddCommand("sleep 10 && echo callback test", func(result *shellrunner.CommandResult) {
callbackReached = true
if result.Output != "callback test\n" {
t.Fatalf("expected 'callback test', got '%s'", result.Output)
}
}); err != nil {
t.Fatalf("unexpected error adding command: %v", err)
}
// 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")
}
if callbackReached {
t.Fatal("callback was reached before kill")
}
}
func TestStopPreventsNewCommands(t *testing.T) {
runner := shellrunner.NewShellRunner()
runner.Start()
runner.Stop()
err := runner.AddCommand("echo should not run", nil)
if err == nil {
t.Fatal("expected error when adding command after stop, but got none")
}
}