Skip to content

Commit c787fd9

Browse files
ensure command cancellation works
1 parent 3f90f56 commit c787fd9

5 files changed

Lines changed: 167 additions & 0 deletions

File tree

checks/cli.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ func runCLICommandWithLimits(
8585
cmd = exec.CommandContext(ctx, "sh", "-c", finalCommand)
8686
}
8787

88+
configureCommandCancellation(cmd)
8889
cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
8990
cmd.WaitDelay = commandWaitDelay
9091
cancelForOutputLimit := func() {
@@ -94,6 +95,8 @@ func runCLICommandWithLimits(
9495
stderr := newBoundedBuffer(maxOutputBytesPerStream, cancelForOutputLimit)
9596
cmd.Stdout = stdout
9697
cmd.Stderr = stderr
98+
stopSignalForwarding := forwardSignalsToCommand(cmd)
99+
defer stopSignalForwarding()
97100
err := cmd.Run()
98101
if ee, ok := err.(*exec.ExitError); ok {
99102
result.ExitCode = ee.ExitCode()

checks/command_process_other.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
2+
3+
package checks
4+
5+
import "os/exec"
6+
7+
func configureCommandCancellation(cmd *exec.Cmd) {}
8+
9+
func forwardSignalsToCommand(cmd *exec.Cmd) func() {
10+
return func() {}
11+
}

checks/command_process_unix.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
2+
3+
package checks
4+
5+
import (
6+
"errors"
7+
"os"
8+
"os/exec"
9+
"os/signal"
10+
"sync"
11+
"syscall"
12+
)
13+
14+
func configureCommandCancellation(cmd *exec.Cmd) {
15+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
16+
cmd.Cancel = func() error { return killCommandProcessGroup(cmd) }
17+
}
18+
19+
func forwardSignalsToCommand(cmd *exec.Cmd) func() {
20+
signals := make(chan os.Signal, 1)
21+
done := make(chan struct{})
22+
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
23+
var forwardOnce sync.Once
24+
forward := func(received os.Signal) {
25+
forwardOnce.Do(func() {
26+
_ = killCommandProcessGroup(cmd)
27+
signal.Reset(received)
28+
if unixSignal, ok := received.(syscall.Signal); ok {
29+
_ = syscall.Kill(os.Getpid(), unixSignal)
30+
}
31+
})
32+
}
33+
34+
go func() {
35+
select {
36+
case received := <-signals:
37+
forward(received)
38+
case <-done:
39+
}
40+
}()
41+
42+
return func() {
43+
signal.Stop(signals)
44+
select {
45+
case received := <-signals:
46+
forward(received)
47+
default:
48+
}
49+
close(done)
50+
}
51+
}
52+
53+
func killCommandProcessGroup(cmd *exec.Cmd) error {
54+
if cmd.Process == nil {
55+
return os.ErrProcessDone
56+
}
57+
58+
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
59+
if errors.Is(err, syscall.ESRCH) {
60+
return os.ErrProcessDone
61+
}
62+
return err
63+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
2+
3+
package checks
4+
5+
import (
6+
"errors"
7+
"strconv"
8+
"strings"
9+
"syscall"
10+
"testing"
11+
"time"
12+
13+
api "github.com/bootdotdev/bootdev/client"
14+
)
15+
16+
func TestRunCLICommandTimeoutKillsDescendants(t *testing.T) {
17+
result := runCLICommandWithLimits(
18+
api.CLIStepCLICommand{Command: "sleep 30 & echo $!; wait"},
19+
map[string]string{},
20+
100*time.Millisecond,
21+
1024,
22+
)
23+
if !strings.Contains(result.Err, "command timed out") {
24+
t.Fatalf("command error = %q, want timeout error", result.Err)
25+
}
26+
27+
pid, err := strconv.Atoi(strings.TrimSpace(result.Stdout))
28+
if err != nil {
29+
t.Fatalf("child PID output = %q: %v", result.Stdout, err)
30+
}
31+
childAlive := true
32+
t.Cleanup(func() {
33+
if childAlive {
34+
_ = syscall.Kill(pid, syscall.SIGKILL)
35+
}
36+
})
37+
38+
deadline := time.Now().Add(time.Second)
39+
for time.Now().Before(deadline) {
40+
err := syscall.Kill(pid, 0)
41+
if errors.Is(err, syscall.ESRCH) {
42+
childAlive = false
43+
return
44+
}
45+
if err != nil {
46+
t.Fatalf("check child process %d: %v", pid, err)
47+
}
48+
time.Sleep(10 * time.Millisecond)
49+
}
50+
51+
t.Fatalf("child process %d survived command cancellation", pid)
52+
}

checks/command_process_windows.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//go:build windows
2+
3+
package checks
4+
5+
import (
6+
"errors"
7+
"os"
8+
"os/exec"
9+
"strconv"
10+
)
11+
12+
func configureCommandCancellation(cmd *exec.Cmd) {
13+
cmd.Cancel = func() error {
14+
if cmd.Process == nil {
15+
return os.ErrProcessDone
16+
}
17+
18+
treeKill := exec.Command(
19+
"taskkill.exe",
20+
"/PID", strconv.Itoa(cmd.Process.Pid),
21+
"/T",
22+
"/F",
23+
)
24+
if err := treeKill.Run(); err == nil {
25+
return nil
26+
}
27+
28+
err := cmd.Process.Kill()
29+
if errors.Is(err, os.ErrProcessDone) {
30+
return os.ErrProcessDone
31+
}
32+
return err
33+
}
34+
}
35+
36+
func forwardSignalsToCommand(cmd *exec.Cmd) func() {
37+
return func() {}
38+
}

0 commit comments

Comments
 (0)