Skip to content

Commit a85c27d

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 a85c27d

File tree

6 files changed

+492
-0
lines changed

6 files changed

+492
-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

+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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_test
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+
"k8s.io/minikube/pkg/minikube/process"
31+
)
32+
33+
var waitTimeout = 250 * time.Millisecond
34+
35+
func TestPidfile(t *testing.T) {
36+
pidfile := filepath.Join(t.TempDir(), "pid")
37+
if err := process.WritePidfile(pidfile, 42); err != nil {
38+
t.Fatal(err)
39+
}
40+
pid, err := process.ReadPidfile(pidfile)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if pid != 42 {
45+
t.Fatalf("expected 42, got %d", pid)
46+
}
47+
}
48+
49+
func TestPidfileMissing(t *testing.T) {
50+
pidfile := filepath.Join(t.TempDir(), "pid")
51+
_, err := process.ReadPidfile(pidfile)
52+
if !errors.Is(err, os.ErrNotExist) {
53+
t.Fatalf("unexpected error: %s", err)
54+
}
55+
}
56+
57+
func TestPidfileInvalid(t *testing.T) {
58+
pidfile := filepath.Join(t.TempDir(), "pid")
59+
if err := os.WriteFile(pidfile, []byte("invalid"), 0o600); err != nil {
60+
t.Fatal(err)
61+
}
62+
_, err := process.ReadPidfile(pidfile)
63+
if err == nil {
64+
t.Fatal("parsing invalid pidfile did not fail")
65+
}
66+
}
67+
68+
func TestProcess(t *testing.T) {
69+
sleep, err := build(t, "sleep")
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
74+
noterm, err := build(t, "noterm")
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
79+
t.Run("exists", func(t *testing.T) {
80+
cmd := startProcess(t, sleep)
81+
exists, err := process.Exists(cmd.Process.Pid, filepath.Base(sleep))
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
if !exists {
86+
t.Fatal("existing process not found")
87+
}
88+
exists, err = process.Exists(0xfffffffc, filepath.Base(sleep))
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
if exists {
93+
t.Fatal("process with non-existing pid found")
94+
}
95+
exists, err = process.Exists(cmd.Process.Pid, "no-such-executable")
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
if exists {
100+
t.Fatal("process with non-existing executable found")
101+
}
102+
})
103+
104+
t.Run("terminate", func(t *testing.T) {
105+
cmd := startProcess(t, sleep)
106+
err := process.Terminate(cmd.Process.Pid, filepath.Base(sleep))
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
waitForTermination(t, cmd)
111+
exists, err := process.Exists(cmd.Process.Pid, filepath.Base(sleep))
112+
if err != nil {
113+
t.Fatal(err)
114+
}
115+
if exists {
116+
t.Fatalf("reaped process exists")
117+
}
118+
})
119+
120+
t.Run("terminate name mismatch", func(t *testing.T) {
121+
cmd := startProcess(t, sleep)
122+
err := process.Terminate(cmd.Process.Pid, "no-such-process")
123+
if err == nil {
124+
t.Fatalf("Signaled unrelated process")
125+
}
126+
if err != os.ErrProcessDone {
127+
t.Fatalf("Unexpected error: %s", err)
128+
}
129+
})
130+
131+
t.Run("terminate ignored", func(t *testing.T) {
132+
if runtime.GOOS == "windows" {
133+
t.Skip("no way to ignore termination on windows")
134+
}
135+
cmd := startProcess(t, noterm)
136+
err := process.Terminate(cmd.Process.Pid, filepath.Base(noterm))
137+
if err != nil {
138+
t.Fatal(err)
139+
}
140+
time.Sleep(waitTimeout)
141+
exists, err := process.Exists(cmd.Process.Pid, filepath.Base(noterm))
142+
if err != nil {
143+
t.Fatal(err)
144+
}
145+
if !exists {
146+
t.Fatalf("process terminated")
147+
}
148+
})
149+
150+
t.Run("kill", func(t *testing.T) {
151+
cmd := startProcess(t, noterm)
152+
err := process.Kill(cmd.Process.Pid, filepath.Base(noterm))
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
waitForTermination(t, cmd)
157+
exists, err := process.Exists(cmd.Process.Pid, filepath.Base(noterm))
158+
if err != nil {
159+
t.Fatal(err)
160+
}
161+
if exists {
162+
t.Fatalf("reaped process exists")
163+
}
164+
})
165+
166+
t.Run("kill name mismatch", func(t *testing.T) {
167+
cmd := startProcess(t, noterm)
168+
err := process.Kill(cmd.Process.Pid, "no-such-process")
169+
if err == nil {
170+
t.Fatalf("Killed unrelated process")
171+
}
172+
if err != os.ErrProcessDone {
173+
t.Fatalf("Unexpected error: %s", err)
174+
}
175+
})
176+
}
177+
178+
func startProcess(t *testing.T, cmd string) *exec.Cmd {
179+
name := filepath.Base(cmd)
180+
c := exec.Command(cmd)
181+
stdout, err := c.StdoutPipe()
182+
if err != nil {
183+
t.Fatal(err)
184+
}
185+
186+
start := time.Now()
187+
if err := c.Start(); err != nil {
188+
t.Fatal(err)
189+
}
190+
t.Logf("Started process %q (pid=%v)", name, c.Process.Pid)
191+
192+
t.Cleanup(func() {
193+
_ = c.Process.Kill()
194+
_ = c.Wait()
195+
})
196+
197+
// Synchronize with the process to ensure it set up signal handlers before
198+
// we send a signal.
199+
r := bufio.NewReader(stdout)
200+
line, err := r.ReadString('\n')
201+
if err != nil {
202+
t.Fatal(err)
203+
}
204+
if line != "READY\n" {
205+
t.Fatalf("Unexpected response: %q", line)
206+
}
207+
t.Logf("Process %q ready in %.6f seconds", name, time.Since(start).Seconds())
208+
209+
return c
210+
}
211+
212+
func waitForTermination(t *testing.T, cmd *exec.Cmd) {
213+
name := filepath.Base(cmd.Path)
214+
timer := time.AfterFunc(waitTimeout, func() {
215+
t.Fatalf("Timeout waiting for %q", name)
216+
})
217+
defer timer.Stop()
218+
start := time.Now()
219+
err := cmd.Wait()
220+
t.Logf("Process %q terminated in %.6f seconds: %s", name, time.Since(start).Seconds(), err)
221+
}
222+
223+
func build(t *testing.T, name string) (string, error) {
224+
source := fmt.Sprintf("testdata/%s.go", name)
225+
226+
out := filepath.Join(t.TempDir(), name)
227+
if runtime.GOOS == "windows" {
228+
out += ".exe"
229+
}
230+
231+
t.Logf("Building %q", name)
232+
cmd := exec.Command("go", "build", "-o", out, source)
233+
cmd.Stdout = os.Stdout
234+
cmd.Stderr = os.Stderr
235+
236+
if err := cmd.Run(); err != nil {
237+
return "", err
238+
}
239+
return out, nil
240+
}

0 commit comments

Comments
 (0)