From b6530cf85d09be42eb21d4dce545f66afa818fb4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 23:20:28 +0000 Subject: [PATCH] feat(process): track killed process lifecycle --- process_test.go | 11 +++++++ service.go | 79 ++++++++++++++++++++++++++++--------------------- service_test.go | 44 +++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/process_test.go b/process_test.go index 9ef4016..302bc9e 100644 --- a/process_test.go +++ b/process_test.go @@ -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) }) } diff --git a/service.go b/service.go index bc5b52c..f4fd18f 100644 --- a/service.go +++ b/service.go @@ -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, "" +} diff --git a/service_test.go b/service_test.go index db43004..7d3a64e 100644 --- a/service_test.go +++ b/service_test.go @@ -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) {