123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- //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
- }
|