feat(process): add signal task surface

Co-authored-by: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-04 03:28:28 +00:00
parent 04543700bc
commit 79e2ffa6ed
5 changed files with 251 additions and 1 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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}

View file

@ -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)