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