Skip to content

Commit 10ea7ca

Browse files
committed
Add process package
Current code contains multiple implementations for managing a process using pids, with various issues: - Some are unsafe, terminating a process by pid without validating that the pid belongs to the right process. Some use unclear - Using unclear terms like checkPid() (what does it mean?) - Some are missing tests Let's clean up the mess by introducing a process package. The package provides: - process.WritePidfile(): write a pid to file - process.ReadPidfile(): read pid from file - process.Exists(): tells if process matching pid and name exists - process.Terminate() terminates a process matching pid and name - process.Kil() kill a process matching pid and name The library is tested on linux, darwin, and windows. On windows we don't have a standard way to terminate a process gracefully, so process.Terminate() is the same as process.Kill(). I want to use this package in vfkit and the new vment package, and later we can use it for qemu, hyperkit, and other code using managing processes with pids.
1 parent 8ed8f15 commit 10ea7ca

File tree

6 files changed

+490
-0
lines changed

6 files changed

+490
-0
lines changed

pkg/minikube/process/process.go

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors All rights reserved.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package process
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"strconv"
23+
"strings"
24+
25+
"github.com/mitchellh/go-ps"
26+
)
27+
28+
const pidfileMode = 0o600
29+
30+
// WritePidfile writes pid to path.
31+
func WritePidfile(path string, pid int) error {
32+
data := fmt.Sprintf("%d", pid)
33+
return os.WriteFile(path, []byte(data), pidfileMode)
34+
}
35+
36+
// ReadPid reads a pid from path.
37+
func ReadPidfile(path string) (int, error) {
38+
data, err := os.ReadFile(path)
39+
if err != nil {
40+
// Pass os.ErrNotExist
41+
return -1, err
42+
}
43+
s := strings.TrimSpace(string(data))
44+
pid, err := strconv.Atoi(s)
45+
if err != nil {
46+
return -1, fmt.Errorf("invalid pid %q: %s", s, err)
47+
}
48+
return pid, nil
49+
}
50+
51+
// Exists tells if a process matching pid and executable name exist. Executable is
52+
// not the path to the executable.
53+
func Exists(pid int, executable string) (bool, error) {
54+
// Fast path if pid does not exist.
55+
exists, err := pidExists(pid)
56+
if err != nil {
57+
return true, err
58+
}
59+
if !exists {
60+
return false, nil
61+
}
62+
63+
// Slow path if pid exist, depending on the platform. On windows and darwin
64+
// this fetch all processes from the krenel and find a process with pid. On
65+
// linux this reads /proc/pid/stat
66+
entry, err := ps.FindProcess(pid)
67+
if err != nil {
68+
return true, err
69+
}
70+
if entry == nil {
71+
return false, nil
72+
}
73+
return entry.Executable() == executable, nil
74+
}
75+
76+
// Terminate a process with pid and matching name. Returns os.ErrProcessDone if
77+
// the process does not exist, or nil if termiation was requested. Caller need
78+
// to wait until the process disappears.
79+
func Terminate(pid int, executable string) error {
80+
exists, err := Exists(pid, executable)
81+
if err != nil {
82+
return err
83+
}
84+
if !exists {
85+
return os.ErrProcessDone
86+
}
87+
return terminatePid(pid)
88+
}
89+
90+
// Kill a process with pid matching executable name. Returns os.ErrProcessDone
91+
// if the process does not exist or nil the kill was requested. Caller need to
92+
// wait until the process disappears.
93+
func Kill(pid int, executable string) error {
94+
exists, err := Exists(pid, executable)
95+
if err != nil {
96+
return err
97+
}
98+
if !exists {
99+
return os.ErrProcessDone
100+
}
101+
return killPid(pid)
102+
}

pkg/minikube/process/process_other.go

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build !windows
2+
3+
/*
4+
Copyright 2025 The Kubernetes Authors All rights reserved.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package process
20+
21+
import (
22+
"os"
23+
"syscall"
24+
)
25+
26+
func pidExists(pid int) (bool, error) {
27+
// Never fails and we get a process in "done" state that returns
28+
// os.ErrProcessDone from Signal or Wait.
29+
process, err := os.FindProcess(pid)
30+
if err != nil {
31+
return true, err
32+
}
33+
if process.Signal(syscall.Signal(0)) == os.ErrProcessDone {
34+
return false, nil
35+
}
36+
return true, nil
37+
}
38+
39+
func terminatePid(pid int) error {
40+
p, err := os.FindProcess(pid)
41+
if err != nil {
42+
return err
43+
}
44+
return p.Signal(syscall.SIGTERM)
45+
}
46+
47+
func killPid(pid int) error {
48+
p, err := os.FindProcess(pid)
49+
if err != nil {
50+
return err
51+
}
52+
return p.Kill()
53+
}

pkg/minikube/process/process_test.go

+238
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors All rights reserved.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package process
18+
19+
import (
20+
"bufio"
21+
"errors"
22+
"fmt"
23+
"os"
24+
"os/exec"
25+
"path/filepath"
26+
"runtime"
27+
"testing"
28+
"time"
29+
)
30+
31+
var waitTimeout = 250 * time.Millisecond
32+
33+
func TestPidfile(t *testing.T) {
34+
pidfile := filepath.Join(t.TempDir(), "pid")
35+
if err := WritePidfile(pidfile, 42); err != nil {
36+
t.Fatal(err)
37+
}
38+
pid, err := ReadPidfile(pidfile)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
if pid != 42 {
43+
t.Fatalf("expected 42, got %d", pid)
44+
}
45+
}
46+
47+
func TestPidfileMissing(t *testing.T) {
48+
pidfile := filepath.Join(t.TempDir(), "pid")
49+
_, err := ReadPidfile(pidfile)
50+
if !errors.Is(err, os.ErrNotExist) {
51+
t.Fatalf("unexpected error: %s", err)
52+
}
53+
}
54+
55+
func TestPidfileInvalid(t *testing.T) {
56+
pidfile := filepath.Join(t.TempDir(), "pid")
57+
if err := os.WriteFile(pidfile, []byte("invalid"), 0o600); err != nil {
58+
t.Fatal(err)
59+
}
60+
_, err := ReadPidfile(pidfile)
61+
if err == nil {
62+
t.Fatal("parsing invalid pidfile did not fail")
63+
}
64+
}
65+
66+
func TestProcess(t *testing.T) {
67+
sleep, err := build(t, "sleep")
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
72+
noterm, err := build(t, "noterm")
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
77+
t.Run("exists", func(t *testing.T) {
78+
cmd := startProcess(t, sleep)
79+
exists, err := Exists(cmd.Process.Pid, filepath.Base(sleep))
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
if !exists {
84+
t.Fatal("existing process not found")
85+
}
86+
exists, err = Exists(0xfffffffc, filepath.Base(sleep))
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if exists {
91+
t.Fatal("process with non-existing pid found")
92+
}
93+
exists, err = Exists(cmd.Process.Pid, "no-such-executable")
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
if exists {
98+
t.Fatal("process with non-existing executable found")
99+
}
100+
})
101+
102+
t.Run("terminate", func(t *testing.T) {
103+
cmd := startProcess(t, sleep)
104+
err := Terminate(cmd.Process.Pid, filepath.Base(sleep))
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
waitForTermination(t, cmd)
109+
exists, err := Exists(cmd.Process.Pid, filepath.Base(sleep))
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
if exists {
114+
t.Fatalf("reaped process exists")
115+
}
116+
})
117+
118+
t.Run("terminate name mismatch", func(t *testing.T) {
119+
cmd := startProcess(t, sleep)
120+
err := Terminate(cmd.Process.Pid, "no-such-process")
121+
if err == nil {
122+
t.Fatalf("Signaled unrelated process")
123+
}
124+
if err != os.ErrProcessDone {
125+
t.Fatalf("Unexpected error: %s", err)
126+
}
127+
})
128+
129+
t.Run("terminate ignored", func(t *testing.T) {
130+
if runtime.GOOS == "windows" {
131+
t.Skip("no way to ignore termination on windows")
132+
}
133+
cmd := startProcess(t, noterm)
134+
err := Terminate(cmd.Process.Pid, filepath.Base(noterm))
135+
if err != nil {
136+
t.Fatal(err)
137+
}
138+
time.Sleep(waitTimeout)
139+
exists, err := Exists(cmd.Process.Pid, filepath.Base(noterm))
140+
if err != nil {
141+
t.Fatal(err)
142+
}
143+
if !exists {
144+
t.Fatalf("process terminated")
145+
}
146+
})
147+
148+
t.Run("kill", func(t *testing.T) {
149+
cmd := startProcess(t, noterm)
150+
err := Kill(cmd.Process.Pid, filepath.Base(noterm))
151+
if err != nil {
152+
t.Fatal(err)
153+
}
154+
waitForTermination(t, cmd)
155+
exists, err := Exists(cmd.Process.Pid, filepath.Base(noterm))
156+
if err != nil {
157+
t.Fatal(err)
158+
}
159+
if exists {
160+
t.Fatalf("reaped process exists")
161+
}
162+
})
163+
164+
t.Run("kill name mismatch", func(t *testing.T) {
165+
cmd := startProcess(t, noterm)
166+
err := Kill(cmd.Process.Pid, "no-such-process")
167+
if err == nil {
168+
t.Fatalf("Killed unrelated process")
169+
}
170+
if err != os.ErrProcessDone {
171+
t.Fatalf("Unexpected error: %s", err)
172+
}
173+
})
174+
}
175+
176+
func startProcess(t *testing.T, cmd string) *exec.Cmd {
177+
name := filepath.Base(cmd)
178+
c := exec.Command(cmd)
179+
stdout, err := c.StdoutPipe()
180+
if err != nil {
181+
t.Fatal(err)
182+
}
183+
184+
start := time.Now()
185+
if err := c.Start(); err != nil {
186+
t.Fatal(err)
187+
}
188+
t.Logf("Started process %q (pid=%v)", name, c.Process.Pid)
189+
190+
t.Cleanup(func() {
191+
_ = c.Process.Kill()
192+
_ = c.Wait()
193+
})
194+
195+
// Synchronize with the process to ensure it set up signal handlers before
196+
// we send a signal.
197+
r := bufio.NewReader(stdout)
198+
line, err := r.ReadString('\n')
199+
if err != nil {
200+
t.Fatal(err)
201+
}
202+
if line != "READY\n" {
203+
t.Fatalf("Unexpected response: %q", line)
204+
}
205+
t.Logf("Process %q ready in %.6f seconds", name, time.Since(start).Seconds())
206+
207+
return c
208+
}
209+
210+
func waitForTermination(t *testing.T, cmd *exec.Cmd) {
211+
name := filepath.Base(cmd.Path)
212+
timer := time.AfterFunc(waitTimeout, func() {
213+
t.Fatalf("Timeout waiting for %q", name)
214+
})
215+
defer timer.Stop()
216+
start := time.Now()
217+
err := cmd.Wait()
218+
t.Logf("Process %q terminated in %.6f seconds: %s", name, time.Since(start).Seconds(), err)
219+
}
220+
221+
func build(t *testing.T, name string) (string, error) {
222+
source := fmt.Sprintf("testdata/%s.go", name)
223+
224+
out := filepath.Join(t.TempDir(), name)
225+
if runtime.GOOS == "windows" {
226+
out += ".exe"
227+
}
228+
229+
t.Logf("Building %q", name)
230+
cmd := exec.Command("go", "build", "-o", out, source)
231+
cmd.Stdout = os.Stdout
232+
cmd.Stderr = os.Stderr
233+
234+
if err := cmd.Run(); err != nil {
235+
return "", err
236+
}
237+
return out, nil
238+
}

0 commit comments

Comments
 (0)