Skip to content

Commit f7732b7

Browse files
Kill timed-out check process groups
1 parent dab80d3 commit f7732b7

4 files changed

Lines changed: 129 additions & 9 deletions

File tree

internal/app/checks.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
@@ -59,16 +60,12 @@ func runConfiguredChecks(checks []string, workdir string, timeoutSetting string)
5960
}
6061

6162
ctx, cancel := context.WithTimeout(context.Background(), effectiveTimeout)
62-
cmd := exec.CommandContext(ctx, "/bin/sh", "-lc", command)
63-
cmd.Dir = workdir
64-
output, err := cmd.CombinedOutput()
63+
output, err := runTimedCheck(ctx, workdir, command)
6564
cancel()
66-
if ctx.Err() == context.DeadlineExceeded {
67-
return &checkTimeoutError{
68-
Command: command,
69-
RequestedTimeout: timeout,
70-
EffectiveTimeout: effectiveTimeout,
71-
}
65+
if timeoutErr, ok := isCheckTimeoutError(err); ok {
66+
timeoutErr.RequestedTimeout = timeout
67+
timeoutErr.EffectiveTimeout = effectiveTimeout
68+
return timeoutErr
7269
}
7370
if err != nil {
7471
return fmt.Errorf("check %q failed: %s", command, strings.TrimSpace(string(output)))
@@ -78,6 +75,37 @@ func runConfiguredChecks(checks []string, workdir string, timeoutSetting string)
7875
return nil
7976
}
8077

78+
func runTimedCheck(ctx context.Context, workdir string, command string) ([]byte, error) {
79+
cmd := exec.Command("/bin/sh", "-lc", command)
80+
cmd.SysProcAttr = checkSysProcAttr()
81+
cmd.Dir = workdir
82+
var output bytes.Buffer
83+
cmd.Stdout = &output
84+
cmd.Stderr = &output
85+
if err := cmd.Start(); err != nil {
86+
return nil, err
87+
}
88+
89+
resultCh := make(chan error, 1)
90+
go func() {
91+
resultCh <- cmd.Wait()
92+
}()
93+
94+
select {
95+
case err := <-resultCh:
96+
return output.Bytes(), err
97+
case <-ctx.Done():
98+
_ = interruptCheckProcess(cmd.Process.Pid)
99+
err := <-resultCh
100+
if ctx.Err() == context.DeadlineExceeded {
101+
return nil, &checkTimeoutError{
102+
Command: command,
103+
}
104+
}
105+
return output.Bytes(), err
106+
}
107+
}
108+
81109
func resolveCommandTimeout(timeoutSetting string) (time.Duration, time.Duration, error) {
82110
timeout, err := time.ParseDuration(timeoutSetting)
83111
if err != nil {

internal/app/checks_unix_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//go:build unix
2+
3+
package app
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"strconv"
9+
"strings"
10+
"syscall"
11+
"testing"
12+
"time"
13+
)
14+
15+
func TestRunConfiguredChecksTimeoutKillsChildProcessGroup(t *testing.T) {
16+
workdir := t.TempDir()
17+
pidFile := filepath.Join(workdir, "child.pid")
18+
19+
err := runConfiguredChecks([]string{
20+
"sleep 30 & child=$!; echo $child > child.pid; wait $child",
21+
}, workdir, "100ms")
22+
timeoutErr, ok := isCheckTimeoutError(err)
23+
if !ok {
24+
t.Fatalf("expected check timeout error, got %v", err)
25+
}
26+
if timeoutErr == nil {
27+
t.Fatalf("expected timeout error details")
28+
}
29+
30+
var pid int
31+
deadline := time.Now().Add(2 * time.Second)
32+
for {
33+
data, readErr := os.ReadFile(pidFile)
34+
if readErr == nil {
35+
parsedPID, convErr := strconv.Atoi(strings.TrimSpace(string(data)))
36+
if convErr != nil {
37+
t.Fatalf("parse child pid: %v", convErr)
38+
}
39+
pid = parsedPID
40+
break
41+
}
42+
if time.Now().After(deadline) {
43+
t.Fatalf("timed out waiting for child pid file: %v", readErr)
44+
}
45+
time.Sleep(10 * time.Millisecond)
46+
}
47+
48+
deadline = time.Now().Add(2 * time.Second)
49+
for {
50+
killErr := syscall.Kill(pid, 0)
51+
if killErr != nil {
52+
return
53+
}
54+
if time.Now().After(deadline) {
55+
t.Fatalf("timed out waiting for child pid %d to exit", pid)
56+
}
57+
time.Sleep(10 * time.Millisecond)
58+
}
59+
}

internal/app/process_default.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//go:build !unix
2+
3+
package app
4+
5+
import (
6+
"os"
7+
"syscall"
8+
)
9+
10+
func checkSysProcAttr() *syscall.SysProcAttr {
11+
return nil
12+
}
13+
14+
func interruptCheckProcess(pid int) error {
15+
process, err := os.FindProcess(pid)
16+
if err != nil {
17+
return err
18+
}
19+
return process.Kill()
20+
}

internal/app/process_unix.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build unix
2+
3+
package app
4+
5+
import "syscall"
6+
7+
func checkSysProcAttr() *syscall.SysProcAttr {
8+
return &syscall.SysProcAttr{Setpgid: true}
9+
}
10+
11+
func interruptCheckProcess(pid int) error {
12+
return syscall.Kill(-pid, syscall.SIGKILL)
13+
}

0 commit comments

Comments
 (0)