- pkg/api/provider.go: remove banned os/syscall imports; delegate to
new process.KillPID and process.IsPIDAlive exported helpers
- service.go: rename `sr` → `startResult`; add KillPID/IsPIDAlive exports
- runner.go: rename `aggResult` → `aggregate` in all three RunXxx methods;
add usage-example comments on all exported functions
- process.go: replace prose doc-comments with usage-example comments
- buffer.go, registry.go, health.go: replace prose comments with examples
- buffer_test.go: rename TestRingBuffer_Basics_Good → TestBuffer_{Write,String,Reset}_{Good,Bad,Ugly}
- All test files: add missing _Bad and _Ugly variants for all functions
(daemon, health, pidfile, registry, runner, process, program, exec, pkg/api)
Co-Authored-By: Virgil <virgil@lethean.io>
431 lines
11 KiB
Go
431 lines
11 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestProcess_Info_Good(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
|
|
proc := startProc(t, svc, context.Background(), "echo", "hello")
|
|
|
|
<-proc.Done()
|
|
|
|
info := proc.Info()
|
|
assert.Equal(t, proc.ID, info.ID)
|
|
assert.Equal(t, "echo", info.Command)
|
|
assert.Equal(t, []string{"hello"}, info.Args)
|
|
assert.Equal(t, StatusExited, info.Status)
|
|
assert.Equal(t, 0, info.ExitCode)
|
|
assert.Greater(t, info.Duration, time.Duration(0))
|
|
}
|
|
|
|
func TestProcess_Output_Good(t *testing.T) {
|
|
t.Run("captures stdout", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "hello world")
|
|
<-proc.Done()
|
|
assert.Contains(t, proc.Output(), "hello world")
|
|
})
|
|
|
|
t.Run("OutputBytes returns copy", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "test")
|
|
<-proc.Done()
|
|
bytes := proc.OutputBytes()
|
|
assert.NotNil(t, bytes)
|
|
assert.Contains(t, string(bytes), "test")
|
|
})
|
|
}
|
|
|
|
func TestProcess_IsRunning_Good(t *testing.T) {
|
|
t.Run("true while running", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proc := startProc(t, svc, ctx, "sleep", "10")
|
|
assert.True(t, proc.IsRunning())
|
|
|
|
cancel()
|
|
<-proc.Done()
|
|
assert.False(t, proc.IsRunning())
|
|
})
|
|
|
|
t.Run("false after completion", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
<-proc.Done()
|
|
assert.False(t, proc.IsRunning())
|
|
})
|
|
}
|
|
|
|
func TestProcess_Wait_Good(t *testing.T) {
|
|
t.Run("returns nil on success", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "ok")
|
|
err := proc.Wait()
|
|
assert.NoError(t, err)
|
|
})
|
|
|
|
t.Run("returns error on failure", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1")
|
|
err := proc.Wait()
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Done_Good(t *testing.T) {
|
|
t.Run("channel closes on completion", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "test")
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Done channel should have closed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProcess_Kill_Good(t *testing.T) {
|
|
t.Run("terminates running process", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proc := startProc(t, svc, ctx, "sleep", "60")
|
|
assert.True(t, proc.IsRunning())
|
|
|
|
err := proc.Kill()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("process should have been killed")
|
|
}
|
|
assert.Equal(t, StatusKilled, proc.Status)
|
|
assert.Equal(t, -1, proc.ExitCode)
|
|
})
|
|
|
|
t.Run("noop on completed process", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
<-proc.Done()
|
|
err := proc.Kill()
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_SendInput_Good(t *testing.T) {
|
|
t.Run("writes to stdin", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "cat")
|
|
|
|
err := proc.SendInput("hello\n")
|
|
assert.NoError(t, err)
|
|
err = proc.CloseStdin()
|
|
assert.NoError(t, err)
|
|
<-proc.Done()
|
|
assert.Contains(t, proc.Output(), "hello")
|
|
})
|
|
|
|
t.Run("error on completed process", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
<-proc.Done()
|
|
err := proc.SendInput("test")
|
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Signal_Good(t *testing.T) {
|
|
t.Run("sends signal to running process", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proc := startProc(t, svc, ctx, "sleep", "60")
|
|
err := proc.Signal(os.Interrupt)
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("process should have been terminated by signal")
|
|
}
|
|
assert.Equal(t, StatusKilled, proc.Status)
|
|
})
|
|
|
|
t.Run("error on completed process", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
<-proc.Done()
|
|
err := proc.Signal(os.Interrupt)
|
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
|
})
|
|
}
|
|
|
|
func TestProcess_CloseStdin_Good(t *testing.T) {
|
|
t.Run("closes stdin pipe", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "cat")
|
|
err := proc.CloseStdin()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("cat should exit when stdin is closed")
|
|
}
|
|
})
|
|
|
|
t.Run("double close is safe", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "cat")
|
|
err := proc.CloseStdin()
|
|
assert.NoError(t, err)
|
|
<-proc.Done()
|
|
err = proc.CloseStdin()
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Timeout_Good(t *testing.T) {
|
|
t.Run("kills process after timeout", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "sleep",
|
|
Args: []string{"60"},
|
|
Timeout: 200 * time.Millisecond,
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("process should have been killed by timeout")
|
|
}
|
|
assert.False(t, proc.IsRunning())
|
|
assert.Equal(t, StatusKilled, proc.Status)
|
|
})
|
|
|
|
t.Run("no timeout when zero", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "echo",
|
|
Args: []string{"fast"},
|
|
Timeout: 0,
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
<-proc.Done()
|
|
assert.Equal(t, 0, proc.ExitCode)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Shutdown_Good(t *testing.T) {
|
|
t.Run("graceful with grace period", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "sleep",
|
|
Args: []string{"60"},
|
|
GracePeriod: 100 * time.Millisecond,
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
|
|
assert.True(t, proc.IsRunning())
|
|
err := proc.Shutdown()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("shutdown should have completed")
|
|
}
|
|
})
|
|
|
|
t.Run("immediate kill without grace period", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "sleep",
|
|
Args: []string{"60"},
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
|
|
err := proc.Shutdown()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("kill should be immediate")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProcess_KillGroup_Good(t *testing.T) {
|
|
t.Run("kills child processes", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "sh",
|
|
Args: []string{"-c", "sleep 60 & wait"},
|
|
Detach: true,
|
|
KillGroup: true,
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
err := proc.Kill()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("process group should have been killed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
|
|
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "sleep",
|
|
Args: []string{"60"},
|
|
Timeout: 200 * time.Millisecond,
|
|
GracePeriod: 100 * time.Millisecond,
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("process should have been killed by timeout")
|
|
}
|
|
assert.Equal(t, StatusKilled, proc.Status)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Info_Bad(t *testing.T) {
|
|
t.Run("failed process has StatusFailed", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.Start(context.Background(), "nonexistent_command_xyz")
|
|
assert.False(t, r.OK)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Info_Ugly(t *testing.T) {
|
|
t.Run("info is safe to call concurrently", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "sleep", "1")
|
|
defer func() { _ = proc.Kill() }()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for i := 0; i < 50; i++ {
|
|
_ = proc.Info()
|
|
}
|
|
}()
|
|
for i := 0; i < 50; i++ {
|
|
_ = proc.Info()
|
|
}
|
|
<-done
|
|
_ = proc.Kill()
|
|
<-proc.Done()
|
|
})
|
|
}
|
|
|
|
func TestProcess_Wait_Bad(t *testing.T) {
|
|
t.Run("returns error for non-zero exit code", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 2")
|
|
err := proc.Wait()
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Wait_Ugly(t *testing.T) {
|
|
t.Run("wait on killed process returns error", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "sleep", "60")
|
|
_ = proc.Kill()
|
|
err := proc.Wait()
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Kill_Bad(t *testing.T) {
|
|
t.Run("kill after kill is idempotent", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proc := startProc(t, svc, ctx, "sleep", "60")
|
|
_ = proc.Kill()
|
|
<-proc.Done()
|
|
|
|
// Second kill should be a no-op (process not running)
|
|
err := proc.Kill()
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Kill_Ugly(t *testing.T) {
|
|
t.Run("shutdown immediate when grace period is zero", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "sleep", "60")
|
|
|
|
err := proc.Shutdown()
|
|
assert.NoError(t, err)
|
|
|
|
select {
|
|
case <-proc.Done():
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("should have been killed immediately")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProcess_SendInput_Ugly(t *testing.T) {
|
|
t.Run("send to nil stdin returns error", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
Command: "echo",
|
|
Args: []string{"hi"},
|
|
})
|
|
require.True(t, r.OK)
|
|
proc := r.Value.(*Process)
|
|
<-proc.Done()
|
|
|
|
err := proc.SendInput("data")
|
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
|
})
|
|
}
|
|
|
|
func TestProcess_Signal_Ugly(t *testing.T) {
|
|
t.Run("multiple signals to completed process return error", func(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
<-proc.Done()
|
|
|
|
err := proc.Signal(os.Interrupt)
|
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
|
err = proc.Signal(os.Interrupt)
|
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
|
})
|
|
}
|