Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
package process

import "os"
import (
"os"
"time"
)

type Option func(cfg *Config) error

type Config struct {
Name string
Args []string
Combined bool
StateDir string
KillSignal *int
Environment []string
Stdin *os.File
WorkDir string
Name string
Args []string
Combined bool
StateDir string
KillSignal *int
Environment []string
Stdin *os.File
WorkDir string
GracefulTimeout time.Duration // Time to wait after SIGTERM before SIGKILL
KillProcessGroup bool // Whether to kill entire process group (default true)
}

func DefaultConfig() *Config {
return &Config{
Environment: os.Environ(),
Environment: os.Environ(),
KillProcessGroup: true, // Default to killing process group
GracefulTimeout: 15 * time.Second, // Default 15 second grace period
}
}

Expand Down
14 changes: 4 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
module github.com/mudler/go-processmanager

go 1.18
go 1.24.0

toolchain go1.24.11

require (
github.com/onsi/ginkgo v1.16.4
github.com/onsi/gomega v1.16.0
github.com/shirou/gopsutil/v4 v4.24.7
golang.org/x/sys v0.40.0
)

require (
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/nxadm/tail v1.4.8 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.3.6 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
Expand Down
31 changes: 2 additions & 29 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
Expand All @@ -21,11 +18,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
Expand All @@ -37,25 +30,10 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.24.7 h1:V9UGTK4gQ8HvcnPKf6Zt3XHyQq/peaekfxpJ2HSocJk=
github.com/shirou/gopsutil/v4 v4.24.7/go.mod h1:0uW/073rP7FYLOkvxolUQM5rMOLTNmRXnFKafpb71rw=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand All @@ -74,19 +52,15 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
Expand Down Expand Up @@ -118,4 +92,3 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
17 changes: 17 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package process

import (
"os"
"time"
)

// WithKillSignal sets the given signal while attemping to stop. Defaults to 9
Expand Down Expand Up @@ -61,3 +62,19 @@ func WithWorkDir(s string) func(cfg *Config) error {
return nil
}
}

// WithGracefulTimeout sets the duration to wait after SIGTERM before SIGKILL
func WithGracefulTimeout(d time.Duration) func(cfg *Config) error {
return func(cfg *Config) error {
cfg.GracefulTimeout = d
return nil
}
}

// WithKillProcessGroup enables or disables killing the entire process group
func WithKillProcessGroup(b bool) func(cfg *Config) error {
return func(cfg *Config) error {
cfg.KillProcessGroup = b
return nil
}
}
68 changes: 51 additions & 17 deletions process.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import (
"path/filepath"
"strconv"
"syscall"

"github.com/shirou/gopsutil/v4/process"
"time"
)

type Process struct {
Expand Down Expand Up @@ -91,6 +90,14 @@ func (p *Process) Run() error {
}
}

// Set the current process as a subreaper to manage orphaned child processes
// This ensures that when child processes terminate, their zombie processes
// are reparented to us instead of init, allowing proper cleanup
if err := SetSubreaper(); err != nil {
// Non-fatal error - log and continue
// This will fail on non-Linux systems but that's expected
}

wd := p.config.WorkDir
if wd == "" {
var err error
Expand All @@ -108,6 +115,7 @@ func (p *Process) Run() error {
NewLog(p.StdoutPath()),
NewLog(p.StderrPath()),
},
Sys: getSysProcAttr(),
}
args := append([]string{p.config.Name}, p.config.Args...)
process, err := os.StartProcess(p.config.Name, args, proc)
Expand Down Expand Up @@ -152,30 +160,51 @@ func (p *Process) IsAlive() bool {
return false
}

// Stop stops the running process by senging KillSignal to the PID annotated in the pidfile
// Stop stops the running process by sending KillSignal to the PID annotated in the pidfile
func (p *Process) Stop() error {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot since we are here at touching Stop(), let's improve errors that are returned such as we can identify better where they are coming from, and don't shadow errors, but wrap them where necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved in 2b22273. The Stop() method now:

  • Wraps errors with fmt.Errorf and %w to preserve error chains
  • Includes contextual information (PID values, signal types) in error messages
  • Checks and reports errors from killProcess() calls instead of ignoring them
  • Provides specific error messages for each failure scenario (read PID, parse PID, send signal)

pid, err := p.readPID()
if err != nil || pid == "" {
return errors.New("no pid")
if err != nil {
return fmt.Errorf("failed to read PID: %w", err)
}
// convert pid string to int32
if pid == "" {
return errors.New("stop failed: PID is empty")
}

// convert pid string to int
pidInt, err := strconv.ParseInt(pid, 10, 64)
if err != nil {
return err
return fmt.Errorf("failed to parse PID %q: %w", pid, err)
}

backendProcess, err := process.NewProcess(int32(pidInt))
if err != nil {
return err
// Determine which signal to send
signal := syscall.SIGTERM
if p.config.KillSignal != nil {
signal = syscall.Signal(*p.config.KillSignal)
}

if p.config.KillSignal != nil {
backendProcess.SendSignal(syscall.Signal(*p.config.KillSignal))
} else {
backendProcess.SendSignal(syscall.SIGTERM)
err = backendProcess.Kill()
if err != nil {
return err
// Send the initial signal (SIGTERM or custom KillSignal)
if err := killProcess(int(pidInt), signal, p.config.KillProcessGroup); err != nil {
return fmt.Errorf("failed to send signal %v to process %d: %w", signal, pidInt, err)
}

// Wait for graceful timeout then send SIGKILL if the process is still alive
if p.config.GracefulTimeout > 0 {
// Wait for the graceful timeout period
deadline := time.Now().Add(p.config.GracefulTimeout)
for time.Now().Before(deadline) {
if !p.IsAlive() {
// Process has terminated, no need to force kill
p.release()
return nil
}
time.Sleep(100 * time.Millisecond)
}

// If still alive after grace period, send SIGKILL
if p.IsAlive() {
if err := killProcess(int(pidInt), syscall.SIGKILL, p.config.KillProcessGroup); err != nil {
return fmt.Errorf("failed to send SIGKILL to process %d: %w", pidInt, err)
}
}
}

Expand Down Expand Up @@ -208,6 +237,11 @@ func (p *Process) monitor() {
if p.proc == nil {
return
}

// Start a goroutine to reap orphaned child processes
// This is needed when we're acting as a subreaper
go p.reapChildren()

status := make(chan *os.ProcessState)
died := make(chan error)
go func() {
Expand Down
47 changes: 47 additions & 0 deletions process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,51 @@ exit 2
Expect(e).To(Equal("2"))
})
})

Context("process group termination", func() {
It("kills child processes when parent is stopped", func() {
dir, err := os.MkdirTemp(os.TempDir(), "")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(dir)

// Start a parent process that spawns a child process
p := New(
WithStateDir(dir),
WithName("/bin/bash"),
WithArgs("-c", `
echo "starting"

# Start a child process that runs indefinitely
(
while true; do
sleep 1
done
) &

# Parent also runs indefinitely
while true; do
sleep 1
done
`),
)
Expect(p.Run()).ToNot(HaveOccurred())

// Wait for the process to start
Eventually(func() string {
c, _ := os.ReadFile(p.StdoutPath())
return string(c)
}, "5s").Should(ContainSubstring("starting"))

// Verify process is alive
Expect(p.IsAlive()).To(BeTrue())

// Stop the parent process (should kill entire process group)
Expect(p.Stop()).ToNot(HaveOccurred())

// Verify the process is no longer alive
Eventually(func() bool {
return p.IsAlive()
}, "10s").Should(BeFalse())
})
})
})
58 changes: 58 additions & 0 deletions process_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//go:build unix

package process

import (
"syscall"
"time"
)

// getSysProcAttr returns the platform-specific syscall attributes for process creation
func getSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true, // Create new process group on Unix
}
}

// killProcess sends a signal to a process or process group
func killProcess(pid int, signal syscall.Signal, killProcessGroup bool) error {
target := pid
if killProcessGroup {
// Use negative PID to target the entire process group on Unix
target = -pid
}
return syscall.Kill(target, signal)
}

// reapChildren waits for and reaps orphaned child processes
// This is called when the process manager is acting as a subreaper
func (p *Process) reapChildren() {
for {
// Wait for any child process with WNOHANG (non-blocking)
var status syscall.WaitStatus
pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil)

// If we successfully reaped a child, continue the loop immediately
// to check for more children
if err == nil && pid > 0 {
continue
}

// No children to reap right now (ECHILD means no children, pid == 0 means no children ready)
if err == syscall.ECHILD || pid == 0 {
// Check if the main process is still alive
// If not, we can exit the reaper
if p.proc == nil || !p.IsAlive() {
return
}
// Sleep briefly before checking again to prevent busy-waiting
time.Sleep(100 * time.Millisecond)
continue
}

// For other errors, exit the reaper
if err != nil {
return
}
}
}
Loading
Loading