Skip to content

Commit dac5582

Browse files
committed
feat: add elegant ending process library
1 parent fd885f9 commit dac5582

File tree

7 files changed

+550
-0
lines changed

7 files changed

+550
-0
lines changed

pkg/process/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
## Go Process Kill Utility
2+
3+
A simple, cross-platform Go package for terminating processes by their PID. It prioritizes a graceful shutdown before resorting to a force kill, ensuring that applications have a chance to clean up resources.
4+
5+
### Features
6+
7+
- **Cross-Platform:** Works seamlessly on Windows, Linux, and macOS.
8+
- **Graceful Shutdown First:** Always attempts to terminate a process gracefully before forcing it to exit.
9+
- **Automatic Fallback:** If a graceful shutdown fails or the process does not exit within a timeout period (5 seconds), it automatically performs a force kill.
10+
- **Simple API:** A single `Kill(pid)` function makes it incredibly easy to use.
11+
12+
<br>
13+
14+
### Usage
15+
16+
Here is a simple example of how to start a process and then terminate it using this package.
17+
18+
```go
19+
package main
20+
21+
import (
22+
"fmt"
23+
"log"
24+
"os/exec"
25+
"runtime"
26+
"time"
27+
28+
"github.com/go-dev-frame/sponge/pkg/process"
29+
)
30+
31+
func main() {
32+
var cmd *exec.Cmd
33+
34+
// Start a long-running process appropriate for the OS.
35+
if runtime.GOOS == "windows" {
36+
cmd = exec.Command("timeout", "/t", "30")
37+
} else {
38+
cmd = exec.Command("sleep", "30")
39+
}
40+
41+
err := cmd.Start()
42+
if err != nil {
43+
log.Fatalf("Failed to start command: %v", err)
44+
}
45+
46+
pid := cmd.Process.Pid
47+
fmt.Printf("Started process with PID: %d\n", pid)
48+
49+
// Give the process a moment to initialize.
50+
time.Sleep(1 * time.Second)
51+
fmt.Printf("Attempting to kill process %d...\n", pid)
52+
53+
// Use the Kill function to terminate it.
54+
if err = process.Kill(pid); err != nil {
55+
log.Fatalf("Failed to kill process: %v", err)
56+
}
57+
58+
fmt.Printf("Successfully terminated process %d.\n", pid)
59+
60+
// The cmd.Wait() call will now return an error because the process was killed.
61+
err = cmd.Wait()
62+
fmt.Printf("Process wait result: %v\n", err)
63+
}
64+
```

pkg/process/kill.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Package process provides functions to manage processes.
2+
package process
3+
4+
import (
5+
"fmt"
6+
"time"
7+
)
8+
9+
// Kill terminates a process by its PID.
10+
func Kill(pid int) error {
11+
if pid < 1 {
12+
return fmt.Errorf("invalid PID: %d", pid)
13+
}
14+
15+
// 1. First, attempt a graceful shutdown.
16+
ok := tryGracefulExit(pid)
17+
if ok {
18+
// Wait for confirmation of exit: check every 0.25s for up to 10 times (5s total).
19+
waitInterval := 250 * time.Millisecond
20+
maxAttempts := 20
21+
for i := 0; i < maxAttempts; i++ {
22+
time.Sleep(waitInterval)
23+
if !isProcessRunning(pid) {
24+
return nil
25+
}
26+
}
27+
} else {
28+
// If the graceful signal failed, check if the process is already gone.
29+
if !isProcessRunning(pid) {
30+
return nil
31+
}
32+
}
33+
34+
// 2. If graceful shutdown failed or timed out, force kill the process.
35+
ok = forceKill(pid)
36+
if !ok {
37+
// If force kill failed, do one last check to see if it's running.
38+
if !isProcessRunning(pid) {
39+
return nil
40+
}
41+
return fmt.Errorf("failed to terminate PID=%d (permissions or process does not exist)", pid)
42+
}
43+
return nil
44+
}

pkg/process/kill_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package process
2+
3+
import (
4+
"os/exec"
5+
"runtime"
6+
"testing"
7+
"time"
8+
)
9+
10+
func startTestProcess(t *testing.T) *exec.Cmd {
11+
t.Helper()
12+
var cmd *exec.Cmd
13+
if runtime.GOOS == "windows" {
14+
cmd = getTestProcessCmd("timeout", "/t", "30")
15+
} else {
16+
cmd = getTestProcessCmd("sleep", "30")
17+
}
18+
err := cmd.Start()
19+
if err != nil {
20+
t.Fatalf("Failed to start test process: %v", err)
21+
}
22+
return cmd
23+
}
24+
25+
func TestKill_InvalidPID(t *testing.T) {
26+
err := Kill(0)
27+
if err == nil {
28+
t.Error("Expected an error for invalid PID 0, but got nil")
29+
}
30+
}
31+
32+
func TestKill_GracefulExitSuccess(t *testing.T) {
33+
cmd := startTestProcess(t)
34+
pid := cmd.Process.Pid
35+
36+
// Ensure the process is running before trying to kill it.
37+
if !isProcessRunning(pid) {
38+
t.Fatalf("Test process with PID %d should be running but it's not", pid)
39+
}
40+
41+
err := Kill(pid)
42+
if err != nil {
43+
t.Errorf("Kill should have succeeded gracefully, but got error: %v", err)
44+
}
45+
46+
// Give it a moment to die, then check.
47+
time.Sleep(1 * time.Second)
48+
49+
if isProcessRunning(pid) {
50+
if ok := forceKill(pid); !ok {
51+
t.Errorf("Process %d should have been terminated gracefully, but it is still running.", pid)
52+
}
53+
}
54+
}
55+
56+
func TestKill_ForceKillAfterGracefulTimeout(t *testing.T) {
57+
cmd := startTestProcess(t)
58+
pid := cmd.Process.Pid
59+
60+
// To test the timeout path, we'll let Kill do its thing.
61+
// The 5-second wait in Kill should expire, triggering forceKill.
62+
err := Kill(pid)
63+
if err != nil {
64+
t.Errorf("Kill should have eventually succeeded with force kill, but got error: %v", err)
65+
}
66+
67+
// We wait slightly longer than the internal timeout of Kill()
68+
time.Sleep(6 * time.Second)
69+
70+
if isProcessRunning(pid) {
71+
if err = cmd.Process.Kill(); err != nil {
72+
t.Errorf("Process %d should have been force-killed, but it is still running.", pid)
73+
}
74+
}
75+
}
76+
77+
func TestKill_NonExistentProcess(t *testing.T) {
78+
pid := 999999
79+
for isProcessRunning(pid) {
80+
pid++
81+
}
82+
83+
err := Kill(pid)
84+
if err != nil {
85+
t.Errorf("Killing a non-existent process should not return an error, but got: %v", err)
86+
}
87+
}
88+
89+
func TestKill_ForceKillFailure(t *testing.T) {
90+
// This scenario is hard to test reliably without special permissions.
91+
// For example, trying to kill a critical system process (PID 1 on Linux).
92+
// Such a test would be flaky and dangerous.
93+
// Instead, we will test the code path where forceKill *reports* failure,
94+
// but the process is actually already gone.
95+
96+
// We can't easily mock `forceKill` to return false, so we'll test the
97+
// logic by killing a process and then immediately trying to kill it again.
98+
// The second kill attempt might fail on the `forceKill` call, but since
99+
// `isProcessRunning` will be false, `Kill` should return nil.
100+
cmd := startTestProcess(t)
101+
pid := cmd.Process.Pid
102+
103+
// Kill it once for real
104+
forceKill(pid)
105+
time.Sleep(100 * time.Millisecond) // Let it die
106+
107+
if isProcessRunning(pid) {
108+
t.Skip("Could not kill process for the first time, skipping test.")
109+
}
110+
111+
// Now call the main Kill function on the already-dead process.
112+
// Internally, it might try graceful, then force, which might fail,
113+
// but the final check should show the process is not running.
114+
err := Kill(pid)
115+
if err != nil {
116+
t.Errorf("Expected nil error when Kill fails to force-kill an already-dead process, but got: %v", err)
117+
}
118+
}
119+
120+
func TestIsProcessRunning_False(t *testing.T) {
121+
pid := 999999
122+
for isProcessRunning(pid) { // ensure PID is not running
123+
pid++
124+
}
125+
if isProcessRunning(pid) {
126+
t.Errorf("isProcessRunning for non-existent PID %d should be false", pid)
127+
}
128+
}
129+
130+
func getTestProcessCmd(name string, arg ...string) *exec.Cmd {
131+
return exec.Command(name, arg...)
132+
}

pkg/process/kill_unix.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//go:build linux || darwin || freebsd || openbsd || netbsd
2+
// +build linux darwin freebsd openbsd netbsd
3+
4+
package process
5+
6+
import (
7+
"syscall"
8+
)
9+
10+
// tryGracefulExit: sends SIGTERM, returns true if the signal was sent successfully (does not guarantee immediate exit).
11+
// On Unix, sending SIGTERM is the conventional way to request a graceful shutdown.
12+
func tryGracefulExit(pid int) bool {
13+
// A nil error from syscall.Kill indicates the signal was sent successfully
14+
// (i.e., the target process exists and received the signal).
15+
err := syscall.Kill(pid, syscall.SIGTERM)
16+
return err == nil
17+
}
18+
19+
// forceKill: sends SIGKILL to forcibly terminate the process.
20+
func forceKill(pid int) bool {
21+
err := syscall.Kill(pid, syscall.SIGKILL)
22+
return err == nil
23+
}
24+
25+
// isProcessRunning: checks if a process exists using syscall.Kill(pid, 0).
26+
func isProcessRunning(pid int) bool {
27+
err := syscall.Kill(pid, 0)
28+
// err == nil => process exists
29+
// err == ESRCH => process does not exist
30+
// err == EPERM => process exists, but we don't have permission to send a signal (we count this as running)
31+
if err == nil {
32+
return true
33+
}
34+
// If the error is "operation not permitted," it means the process exists, but we lack permissions.
35+
if err == syscall.EPERM {
36+
return true
37+
}
38+
// Other errors (like ESRCH) indicate the process does not exist or another issue occurred.
39+
return false
40+
}

pkg/process/kill_unix_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//go:build linux || darwin || freebsd || openbsd || netbsd
2+
// +build linux darwin freebsd openbsd netbsd
3+
4+
package process
5+
6+
import (
7+
"os/exec"
8+
"testing"
9+
"time"
10+
)
11+
12+
// Platform-specific implementation for starting a test process.
13+
//func getTestProcessCmd(name string, arg ...string) *exec.Cmd {
14+
// cmd := exec.Command(name, arg...)
15+
// // Set a process group ID, so we can kill the whole group if needed,
16+
// // and to prevent signals from affecting the test runner.
17+
// cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
18+
// return cmd
19+
//}
20+
21+
func TestUnix_IsProcessRunning_True(t *testing.T) {
22+
cmd := exec.Command("sleep", "10")
23+
err := cmd.Start()
24+
if err != nil {
25+
t.Fatalf("Failed to start process: %v", err)
26+
}
27+
defer cmd.Process.Kill()
28+
29+
pid := cmd.Process.Pid
30+
if !isProcessRunning(pid) {
31+
t.Errorf("isProcessRunning for PID %d should be true", pid)
32+
}
33+
}
34+
35+
func TestUnix_TryGracefulExit(t *testing.T) {
36+
cmd := exec.Command("sleep", "10")
37+
err := cmd.Start()
38+
if err != nil {
39+
t.Fatalf("Failed to start process: %v", err)
40+
}
41+
pid := cmd.Process.Pid
42+
43+
ok := tryGracefulExit(pid)
44+
if !ok {
45+
cmd.Process.Kill()
46+
t.Fatal("tryGracefulExit failed, but it should have succeeded")
47+
}
48+
49+
// Wait for the process to exit
50+
err = cmd.Wait()
51+
if err == nil {
52+
t.Errorf("Process should have been terminated with a signal, but exited cleanly")
53+
}
54+
55+
if isProcessRunning(pid) {
56+
t.Errorf("Process %d should not be running after graceful exit", pid)
57+
}
58+
}
59+
60+
func TestUnix_ForceKill(t *testing.T) {
61+
cmd := exec.Command("sleep", "10")
62+
err := cmd.Start()
63+
if err != nil {
64+
t.Fatalf("Failed to start process: %v", err)
65+
}
66+
pid := cmd.Process.Pid
67+
68+
ok := forceKill(pid)
69+
if !ok {
70+
err = cmd.Process.Kill()
71+
if err != nil {
72+
t.Fatal("forceKill failed, but it should have succeeded")
73+
}
74+
}
75+
76+
time.Sleep(time.Second * 2) // Give OS time to update process table
77+
78+
if isProcessRunning(pid) {
79+
t.Logf("Process %d should not be running after force kill", pid)
80+
}
81+
}

0 commit comments

Comments
 (0)