From 79e2ffa6ed3b06643953eb0b15ff0e8f36cd767c Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:28:28 +0000 Subject: [PATCH] feat(process): add signal task surface Co-authored-by: Virgil --- actions.go | 19 ++++++++++- global_test.go | 65 +++++++++++++++++++++++++++++++++++++ process_global.go | 27 ++++++++++++++++ service.go | 59 ++++++++++++++++++++++++++++++++++ service_test.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) diff --git a/actions.go b/actions.go index dbd26c7..ecc7239 100644 --- a/actions.go +++ b/actions.go @@ -1,6 +1,9 @@ package process -import "time" +import ( + "syscall" + "time" +) // --- ACTION messages (broadcast via Core.ACTION) --- @@ -62,6 +65,20 @@ type TaskProcessKill struct { PID int } +// TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessSignal{ID: "proc-1", Signal: syscall.SIGTERM}) +type TaskProcessSignal struct { + // ID identifies a managed process started by this service. + ID string + // PID targets a process directly when ID is not available. + PID int + // Signal is delivered to the process or process group. + Signal syscall.Signal +} + // TaskProcessGet requests a snapshot of a managed process through Core.PERFORM. // // Example: diff --git a/global_test.go b/global_test.go index aef2035..8abd570 100644 --- a/global_test.go +++ b/global_test.go @@ -2,8 +2,11 @@ package process import ( "context" + "os/exec" "sync" + "syscall" "testing" + "time" framework "dappco.re/go/core" "github.com/stretchr/testify/assert" @@ -270,6 +273,68 @@ func TestGlobal_Output(t *testing.T) { assert.Contains(t, output, "global-output") } +func TestGlobal_Signal(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = Signal(proc.ID, syscall.SIGTERM) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through the global helper") + } +} + +func TestGlobal_SignalPID(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled through the global helper") + } +} + func TestGlobal_Running(t *testing.T) { svc, _ := newTestService(t) diff --git a/process_global.go b/process_global.go index e7d08f1..0f76f71 100644 --- a/process_global.go +++ b/process_global.go @@ -2,6 +2,7 @@ package process import ( "context" + "os" "sync" "sync/atomic" @@ -152,6 +153,32 @@ func KillPID(pid int) error { return svc.KillPID(pid) } +// Signal sends a signal to a process by ID using the default service. +// +// Example: +// +// _ = process.Signal("proc-1", syscall.SIGTERM) +func Signal(id string, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Signal(id, sig) +} + +// SignalPID sends a signal to a process by operating-system PID using the default service. +// +// Example: +// +// _ = process.SignalPID(1234, syscall.SIGTERM) +func SignalPID(pid int, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.SignalPID(pid, sig) +} + // StartWithOptions spawns a process with full configuration using the default service. // // Example: diff --git a/service.go b/service.go index f11587b..5b3ae31 100644 --- a/service.go +++ b/service.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "sort" "sync" @@ -438,6 +439,45 @@ func (s *Service) KillPID(pid int) error { return nil } +// Signal sends a signal to a process by ID. +// +// Example: +// +// _ = svc.Signal("proc-1", syscall.SIGTERM) +func (s *Service) Signal(id string, sig os.Signal) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.Signal(sig) +} + +// SignalPID sends a signal to a process by operating-system PID. +// +// Example: +// +// _ = svc.SignalPID(1234, syscall.SIGTERM) +func (s *Service) SignalPID(pid int, sig os.Signal) error { + if pid <= 0 { + return coreerr.E("Service.SignalPID", "pid must be positive", nil) + } + + if proc := s.findByPID(pid); proc != nil { + return proc.Signal(sig) + } + + target, err := os.FindProcess(pid) + if err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to find pid %d", pid), err) + } + + if err := target.Signal(sig); err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to signal pid %d", pid), err) + } + + return nil +} + // Remove removes a completed process from the list. // // Example: @@ -602,6 +642,25 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { default: return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} } + case TaskProcessSignal: + if m.Signal == 0 { + return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires a signal", nil), OK: false} + } + + switch { + case m.ID != "": + if err := s.Signal(m.ID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + case m.PID > 0: + if err := s.SignalPID(m.PID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + default: + return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires an id or pid", nil), OK: false} + } case TaskProcessGet: if m.ID == "" { return core.Result{Value: coreerr.E("Service.handleTask", "task process get requires an id", nil), OK: false} diff --git a/service_test.go b/service_test.go index 58ac612..46c0985 100644 --- a/service_test.go +++ b/service_test.go @@ -518,6 +518,64 @@ func TestService_KillPID(t *testing.T) { }) } +func TestService_Signal(t *testing.T) { + t.Run("signals running process by id", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = svc.Signal(proc.ID, syscall.SIGTERM) + assert.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + + t.Run("signals unmanaged process by pid", func(t *testing.T) { + svc, _ := newTestService(t) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := svc.SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.True(t, ws.Signaled()) + assert.Equal(t, syscall.SIGTERM, ws.Signal()) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled") + } + }) +} + func TestService_Output(t *testing.T) { t.Run("returns captured output", func(t *testing.T) { svc, _ := newTestService(t) @@ -688,6 +746,30 @@ func TestService_OnStartup(t *testing.T) { assert.NotEmpty(t, killed[0].Signal) }) + t.Run("registers process.signal task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.SIGTERM, + }) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through core") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t)