1
0
mirror of https://github.com/go-task/task.git synced 2024-12-12 10:45:49 +02:00
task/signals_test.go
Pete Davison 5e9851f42f
Update minimum go version (#1758)
* feat: update minimum version to 1.22

* refactor: use int range iterator

* refactor: loop variables

* refactor: replace slicesext.FirstNonZero with cmp.Or

* refactor: use slices.Concat instead of append

* fix: unused param

* fix: linting
2024-08-14 08:37:05 -05:00

240 lines
7.4 KiB
Go

//go:build signals
// +build signals
// This file contains tests for signal handling on Unix.
// Based on code from https://github.com/marco-m/timeit
// Due to how signals work, for robustness we always spawn a separate process;
// we never send signals to the test process.
package task_test
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
var SLEEPIT, _ = filepath.Abs("./bin/sleepit")
func TestSignalSentToProcessGroup(t *testing.T) {
task, err := getTaskPath()
if err != nil {
t.Fatal(err)
}
testCases := map[string]struct {
args []string
sendSigs int
want []string
notWant []string
}{
// regression:
// - child is terminated, immediately, by "context canceled" (another bug???)
"child does not handle sigint: receives sigint and terminates immediately": {
args: []string{task, "--", SLEEPIT, "default", "-sleep=10s"},
sendSigs: 1,
want: []string{
"sleepit: ready\n",
"sleepit: work started\n",
"task: Signal received: \"interrupt\"\n",
// 130 = 128 + SIGINT
"task: Failed to run task \"default\": exit status 130\n",
},
notWant: []string{
"task: Failed to run task \"default\": context canceled\n",
},
},
// 2 regressions:
// - child receives 2 signals instead of 1
// - child is terminated, immediately, by "context canceled" (another bug???)
// TODO we need -cleanup=2s only to show reliably the bug; once the fix is committed,
// we can use -cleanup=50ms to speed the test up
"child intercepts sigint: receives sigint and does cleanup": {
args: []string{task, "--", SLEEPIT, "handle", "-sleep=10s", "-cleanup=2s"},
sendSigs: 1,
want: []string{
"sleepit: ready\n",
"sleepit: work started\n",
"task: Signal received: \"interrupt\"\n",
"sleepit: got signal=interrupt count=1\n",
"sleepit: work canceled\n",
"sleepit: cleanup started\n",
"sleepit: cleanup done\n",
"task: Failed to run task \"default\": exit status 3\n",
},
notWant: []string{
"sleepit: got signal=interrupt count=2\n",
"task: Failed to run task \"default\": context canceled\n",
},
},
// regression: child receives 2 signal instead of 1 and thus terminates abruptly
"child simulates terraform: receives 1 sigint and does cleanup": {
args: []string{task, "--", SLEEPIT, "handle", "-term-after=2", "-sleep=10s", "-cleanup=50ms"},
sendSigs: 1,
want: []string{
"sleepit: ready\n",
"sleepit: work started\n",
"task: Signal received: \"interrupt\"\n",
"sleepit: got signal=interrupt count=1\n",
"sleepit: work canceled\n",
"sleepit: cleanup started\n",
"sleepit: cleanup done\n",
"task: Failed to run task \"default\": exit status 3\n",
},
notWant: []string{
"sleepit: got signal=interrupt count=2\n",
"sleepit: cleanup canceled\n",
"task: Failed to run task \"default\": exit status 4\n",
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
var out bytes.Buffer
sut := exec.Command(tc.args[0], tc.args[1:]...)
sut.Stdout = &out
sut.Stderr = &out
sut.Dir = "testdata/ignore_signals"
// Create a new process group by setting the process group ID of the child
// to the child PID.
// By default, the child would inherit the process group of the parent, but
// we want to avoid this, to protect the parent (the test process) from the
// signal that this test will send. More info in the comments below for
// syscall.Kill().
sut.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pgid: 0}
if err := sut.Start(); err != nil {
t.Fatalf("starting the SUT process: %v", err)
}
// After the child is started, we want to avoid a race condition where we send
// it a signal before it had time to setup its own signal handlers. Sleeping
// is way too flaky, instead we parse the child output until we get a line
// that we know is printed after the signal handlers are installed...
ready := false
timeout := time.Duration(time.Second)
start := time.Now()
for time.Since(start) < timeout {
if strings.Contains(out.String(), "sleepit: ready\n") {
ready = true
break
}
time.Sleep(10 * time.Millisecond)
}
if !ready {
t.Fatalf("sleepit not ready after %v\n"+
"additional information:\n"+
" output:\n%s",
timeout, out.String())
}
// When we have a running program in a shell and type CTRL-C, the tty driver
// will send a SIGINT signal to all the processes in the foreground process
// group (see https://en.wikipedia.org/wiki/Process_group).
//
// Here we want to emulate this behavior: send SIGINT to the process group of
// the test executable. Although Go for some reasons doesn't wrap the
// killpg(2) system call, what works is using syscall.Kill(-PID, SIGINT),
// where the negative PID means the corresponding process group. Note that
// this negative PID works only as long as the caller of the kill(2) system
// call has a different PID, which is the case for this test.
for range tc.sendSigs - 1 {
if err := syscall.Kill(-sut.Process.Pid, syscall.SIGINT); err != nil {
t.Fatalf("sending INT signal to the process group: %v", err)
}
time.Sleep(1 * time.Millisecond)
}
err := sut.Wait()
var wantErr *exec.ExitError
const wantExitStatus = 201
if errors.As(err, &wantErr) {
if wantErr.ExitCode() != wantExitStatus {
t.Errorf(
"waiting for child process: got exit status %v; want %d\n"+
"additional information:\n"+
" process state: %q",
wantErr.ExitCode(), wantExitStatus, wantErr.String())
}
} else {
t.Errorf("waiting for child process: got unexpected error type %v (%T); want (%T)",
err, err, wantErr)
}
gotLines := strings.SplitAfter(out.String(), "\n")
notFound := listDifference(tc.want, gotLines)
if len(notFound) > 0 {
t.Errorf("\nwanted but not found:\n%v", notFound)
}
found := listIntersection(tc.notWant, gotLines)
if len(found) > 0 {
t.Errorf("\nunwanted but found:\n%v", found)
}
if len(notFound) > 0 || len(found) > 0 {
t.Errorf("\noutput:\n%v", gotLines)
}
})
}
}
func getTaskPath() (string, error) {
if info, err := os.Stat("./bin/task"); err == nil {
return info.Name(), nil
}
if path, err := exec.LookPath("task"); err == nil {
return path, nil
}
return "", errors.New("task: \"task\" binary was not found!")
}
// Return the difference of the two lists: the elements that are present in the first
// list, but not in the second one. The notion of presence is not with `=` but with
// string.Contains(l2, l1).
// FIXME this does not enforce ordering. We might want to support both.
func listDifference(lines1, lines2 []string) []string {
difference := []string{}
for _, l1 := range lines1 {
found := false
for _, l2 := range lines2 {
if strings.Contains(l2, l1) {
found = true
break
}
}
if !found {
difference = append(difference, l1)
}
}
return difference
}
// Return the intersection of the two lists: the elements that are present in both lists.
// The notion of presence is not with '=' but with string.Contains(l2, l1)
// FIXME this does not enforce ordering. We might want to support both.
func listIntersection(lines1, lines2 []string) []string {
intersection := []string{}
for _, l1 := range lines1 {
for _, l2 := range lines2 {
if strings.Contains(l2, l1) {
intersection = append(intersection, l1)
break
}
}
}
return intersection
}