diff --git a/process.go b/process.go index f6113a7..1b2fc65 100644 --- a/process.go +++ b/process.go @@ -29,15 +29,17 @@ type Process struct { ExitCode int Duration time.Duration - cmd *exec.Cmd - ctx context.Context - cancel context.CancelFunc - output *RingBuffer - stdin io.WriteCloser - done chan struct{} - mu sync.RWMutex - gracePeriod time.Duration - killGroup bool + cmd *exec.Cmd + ctx context.Context + cancel context.CancelFunc + output *RingBuffer + stdin io.WriteCloser + done chan struct{} + mu sync.RWMutex + gracePeriod time.Duration + killGroup bool + killNotified bool + killSignal string } // Info returns a snapshot of process state. @@ -140,22 +142,28 @@ func (p *Process) Done() <-chan struct{} { // // _ = proc.Kill() func (p *Process) Kill() error { + _, err := p.kill() + return err +} + +// kill terminates the process and reports whether a signal was actually sent. +func (p *Process) kill() (bool, error) { p.mu.Lock() defer p.mu.Unlock() if p.Status != StatusRunning { - return nil + return false, nil } if p.cmd == nil || p.cmd.Process == nil { - return nil + return false, nil } if p.killGroup { // Kill entire process group (negative PID) - return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) + return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) } - return p.cmd.Process.Kill() + return true, p.cmd.Process.Kill() } // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. diff --git a/service.go b/service.go index 7614d51..4f51104 100644 --- a/service.go +++ b/service.go @@ -282,21 +282,16 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce close(proc.done) + if status == StatusKilled { + s.emitKilledAction(proc, signalName) + } + exitAction := ActionProcessExited{ ID: id, ExitCode: exitCode, Duration: duration, Error: exitErr, } - if status == StatusKilled { - exitAction.Error = coreerr.E("Service.StartWithOptions", "process was killed", nil) - if c := s.coreApp(); c != nil { - _ = c.ACTION(ActionProcessKilled{ - ID: id, - Signal: signalName, - }) - } - } if c := s.coreApp(); c != nil { _ = c.ACTION(exitAction) @@ -394,7 +389,14 @@ func (s *Service) Kill(id string) error { return err } - return proc.Kill() + sent, err := proc.kill() + if err != nil { + return err + } + if sent { + s.emitKilledAction(proc, "SIGKILL") + } + return nil } // KillPID terminates a process by operating-system PID. @@ -584,6 +586,34 @@ func classifyProcessExit(err error) (Status, int, error, string) { return StatusFailed, 0, err, "" } +// emitKilledAction broadcasts a kill event once for the given process. +func (s *Service) emitKilledAction(proc *Process, signalName string) { + if proc == nil { + return + } + + proc.mu.Lock() + if proc.killNotified { + proc.mu.Unlock() + return + } + proc.killNotified = true + if signalName != "" { + proc.killSignal = signalName + } else if proc.killSignal == "" { + proc.killSignal = "SIGKILL" + } + signal := proc.killSignal + proc.mu.Unlock() + + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessKilled{ + ID: proc.ID, + Signal: signal, + }) + } +} + // sortProcesses orders processes by start time, then ID for stable output. func sortProcesses(procs []*Process) { sort.Slice(procs, func(i, j int) bool { diff --git a/service_test.go b/service_test.go index aa80c46..cad5544 100644 --- a/service_test.go +++ b/service_test.go @@ -294,6 +294,14 @@ func TestService_Actions(t *testing.T) { err = svc.Kill(proc.ID) require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + + mu.Lock() + require.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) + mu.Unlock() + select { case <-proc.Done(): case <-time.After(2 * time.Second): @@ -304,12 +312,9 @@ func TestService_Actions(t *testing.T) { 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.Len(t, exited, 1) assert.Equal(t, proc.ID, exited[0].ID) - assert.Error(t, exited[0].Error) + assert.NoError(t, exited[0].Error) assert.Equal(t, StatusKilled, proc.Status) })