feat(process): track killed process lifecycle

This commit is contained in:
Virgil 2026-04-03 23:20:28 +00:00
parent 0546b42ce3
commit b6530cf85d
3 changed files with 100 additions and 34 deletions

View file

@ -143,6 +143,8 @@ func TestProcess_Kill(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("process should have been killed")
}
assert.Equal(t, StatusKilled, proc.Status)
})
t.Run("noop on completed process", func(t *testing.T) {
@ -209,6 +211,8 @@ func TestProcess_Signal(t *testing.T) {
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) {
@ -279,6 +283,7 @@ func TestProcess_Timeout(t *testing.T) {
}
assert.False(t, proc.IsRunning())
assert.Equal(t, StatusKilled, proc.Status)
})
t.Run("no timeout when zero", func(t *testing.T) {
@ -319,6 +324,8 @@ func TestProcess_Shutdown(t *testing.T) {
case <-time.After(5 * time.Second):
t.Fatal("shutdown should have completed")
}
assert.Equal(t, StatusKilled, proc.Status)
})
t.Run("immediate kill without grace period", func(t *testing.T) {
@ -367,6 +374,8 @@ func TestProcess_KillGroup(t *testing.T) {
case <-time.After(5 * time.Second):
t.Fatal("process group should have been killed")
}
assert.Equal(t, StatusKilled, proc.Status)
})
}
@ -388,5 +397,7 @@ func TestProcess_TimeoutWithGrace(t *testing.T) {
case <-time.After(5 * time.Second):
t.Fatal("process should have been killed by timeout")
}
assert.Equal(t, StatusKilled, proc.Status)
})
}

View file

@ -220,38 +220,31 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
err := cmd.Wait()
duration := time.Since(proc.StartedAt)
status, exitCode, exitErr, signalName := classifyProcessExit(err)
proc.mu.Lock()
proc.Duration = duration
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
proc.ExitCode = exitErr.ExitCode()
proc.Status = StatusExited
} else {
proc.Status = StatusFailed
}
} else {
proc.ExitCode = 0
proc.Status = StatusExited
}
status := proc.Status
exitCode := proc.ExitCode
proc.ExitCode = exitCode
proc.Status = status
proc.mu.Unlock()
close(proc.done)
// Broadcast exit
var exitErr error
if status == StatusFailed {
exitErr = err
// Broadcast lifecycle completion.
switch status {
case StatusKilled:
_ = s.Core().ACTION(ActionProcessKilled{
ID: id,
Signal: signalName,
})
default:
_ = s.Core().ACTION(ActionProcessExited{
ID: id,
ExitCode: exitCode,
Duration: duration,
Error: exitErr,
})
}
_ = s.Core().ACTION(ActionProcessExited{
ID: id,
ExitCode: exitCode,
Duration: duration,
Error: exitErr,
})
}()
return proc, nil
@ -325,16 +318,7 @@ func (s *Service) Kill(id string) error {
return err
}
if err := proc.Kill(); err != nil {
return err
}
_ = s.Core().ACTION(ActionProcessKilled{
ID: id,
Signal: "SIGKILL",
})
return nil
return proc.Kill()
}
// Remove removes a completed process from the list.
@ -387,6 +371,9 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri
<-proc.Done()
output := proc.Output()
if proc.Status == StatusKilled {
return output, coreerr.E("Service.Run", "process was killed", nil)
}
if proc.ExitCode != 0 {
return output, coreerr.E("Service.Run", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil)
}
@ -403,6 +390,9 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string,
<-proc.Done()
output := proc.Output()
if proc.Status == StatusKilled {
return output, coreerr.E("Service.RunWithOptions", "process was killed", nil)
}
if proc.ExitCode != 0 {
return output, coreerr.E("Service.RunWithOptions", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil)
}
@ -427,3 +417,24 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result {
return core.Result{}
}
}
// classifyProcessExit maps a command completion error to lifecycle state.
func classifyProcessExit(err error) (Status, int, error, string) {
if err == nil {
return StatusExited, 0, nil, ""
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
signalName := ws.Signal().String()
if signalName == "" {
signalName = "signal"
}
return StatusKilled, -1, nil, signalName
}
return StatusExited, exitErr.ExitCode(), nil, ""
}
return StatusFailed, 0, err, ""
}

View file

@ -226,6 +226,48 @@ func TestService_Actions(t *testing.T) {
assert.Len(t, exited, 1)
assert.Equal(t, 0, exited[0].ExitCode)
})
t.Run("broadcasts killed events", func(t *testing.T) {
c := framework.New()
factory := NewService(Options{})
raw, err := factory(c)
require.NoError(t, err)
svc := raw.(*Service)
var killed []ActionProcessKilled
var mu sync.Mutex
c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result {
mu.Lock()
defer mu.Unlock()
if m, ok := msg.(ActionProcessKilled); ok {
killed = append(killed, m)
}
return framework.Result{OK: true}
})
proc, err := svc.Start(context.Background(), "sleep", "60")
require.NoError(t, err)
err = svc.Kill(proc.ID)
require.NoError(t, err)
select {
case <-proc.Done():
case <-time.After(2 * time.Second):
t.Fatal("process should have been killed")
}
time.Sleep(10 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
assert.Len(t, killed, 1)
assert.Equal(t, proc.ID, killed[0].ID)
assert.NotEmpty(t, killed[0].Signal)
assert.Equal(t, StatusKilled, proc.Status)
})
}
func TestService_List(t *testing.T) {
@ -328,6 +370,8 @@ func TestService_Kill(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("process should have been killed")
}
assert.Equal(t, StatusKilled, proc.Status)
})
t.Run("error on unknown id", func(t *testing.T) {