fix(process): reject uncatchable SIGKILL/SIGSTOP at Signal/SignalPID (#919 Cerberus)

Service.Signal and Service.SignalPID now return ErrUncatchableSignal
("signal NN cannot be caught") immediately when caller passes
syscall.SIGKILL or syscall.SIGSTOP. Prevents silent-failure where
handler is registered but never fires.

Tests cover both ID and PID rejection paths.

Closes tasks.lthn.sh/view.php?id=919

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Snider 2026-04-25 12:26:26 +01:00
parent 4f21e14c89
commit 7aea06990d
2 changed files with 61 additions and 0 deletions

View file

@ -30,6 +30,7 @@ var (
ErrProcessNotRunning = coreerr.E("", "process is not running", nil)
ErrStdinNotAvailable = coreerr.E("", "stdin not available", nil)
ErrContextRequired = coreerr.E("", "context is required", nil)
ErrUncatchableSignal = coreerr.E("", "signal cannot be caught", nil)
)
// Service manages process execution with Core IPC integration.
@ -458,6 +459,10 @@ func (s *Service) KillPID(pid int) error {
//
// _ = svc.Signal("proc-1", syscall.SIGTERM)
func (s *Service) Signal(id string, sig os.Signal) error {
if err := validateCatchableSignals(sig); err != nil {
return err
}
proc, err := s.Get(id)
if err != nil {
return err
@ -471,6 +476,10 @@ func (s *Service) Signal(id string, sig os.Signal) error {
//
// _ = svc.SignalPID(1234, syscall.SIGTERM)
func (s *Service) SignalPID(pid int, sig os.Signal) error {
if err := validateCatchableSignals(sig); err != nil {
return err
}
if pid <= 0 {
return ServiceError("pid must be positive", nil)
}
@ -491,6 +500,26 @@ func (s *Service) SignalPID(pid int, sig os.Signal) error {
return nil
}
func validateCatchableSignals(signals ...os.Signal) error {
for _, sig := range signals {
sysSig, ok := sig.(syscall.Signal)
if !ok {
continue
}
switch sysSig {
case syscall.SIGKILL, syscall.SIGSTOP:
return coreerr.E(
"Service.validateCatchableSignals",
core.Sprintf("signal %d cannot be caught", int(sysSig)),
ErrUncatchableSignal,
)
}
}
return nil
}
// Remove removes a completed process from the list.
//
// Example:

View file

@ -3,6 +3,7 @@ package process
import (
"context"
"os/exec"
"strconv"
"strings"
// Note: AX-6 — internal concurrency primitive; structural per RFC §2
"sync"
@ -590,6 +591,37 @@ func TestService_KillPID(t *testing.T) {
}
func TestService_Signal(t *testing.T) {
t.Run("rejects uncatchable signals", func(t *testing.T) {
svc, _ := newTestService(t)
for _, tc := range []struct {
name string
send func(syscall.Signal) error
}{
{
name: "by id",
send: func(sig syscall.Signal) error {
return svc.Signal("nonexistent", sig)
},
},
{
name: "by pid",
send: func(sig syscall.Signal) error {
return svc.SignalPID(999999, sig)
},
},
} {
for _, sig := range []syscall.Signal{syscall.SIGKILL, syscall.SIGSTOP} {
t.Run(tc.name+"/"+strconv.Itoa(int(sig)), func(t *testing.T) {
err := tc.send(sig)
requireError(t, err)
assertErrorIs(t, err, ErrUncatchableSignal)
assertContains(t, err.Error(), "signal "+strconv.Itoa(int(sig))+" cannot be caught")
})
}
}
})
t.Run("signals running process by id", func(t *testing.T) {
svc, _ := newTestService(t)