signals_test.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. //go:build signals
  2. // +build signals
  3. // This file contains tests for signal handling on Unix.
  4. // Based on code from https://github.com/marco-m/timeit
  5. // Due to how signals work, for robustness we always spawn a separate process;
  6. // we never send signals to the test process.
  7. package task_test
  8. import (
  9. "bytes"
  10. "errors"
  11. "os"
  12. "os/exec"
  13. "path/filepath"
  14. "strings"
  15. "syscall"
  16. "testing"
  17. "time"
  18. )
  19. var SLEEPIT, _ = filepath.Abs("./bin/sleepit")
  20. func TestSignalSentToProcessGroup(t *testing.T) {
  21. task, err := getTaskPath()
  22. if err != nil {
  23. t.Fatal(err)
  24. }
  25. testCases := map[string]struct {
  26. args []string
  27. sendSigs int
  28. want []string
  29. notWant []string
  30. }{
  31. // regression:
  32. // - child is terminated, immediately, by "context canceled" (another bug???)
  33. "child does not handle sigint: receives sigint and terminates immediately": {
  34. args: []string{task, "--", SLEEPIT, "default", "-sleep=10s"},
  35. sendSigs: 1,
  36. want: []string{
  37. "sleepit: ready\n",
  38. "sleepit: work started\n",
  39. "task: Signal received: \"interrupt\"\n",
  40. // 130 = 128 + SIGINT
  41. "task: Failed to run task \"default\": exit status 130\n",
  42. },
  43. notWant: []string{
  44. "task: Failed to run task \"default\": context canceled\n",
  45. },
  46. },
  47. // 2 regressions:
  48. // - child receives 2 signals instead of 1
  49. // - child is terminated, immediately, by "context canceled" (another bug???)
  50. // TODO we need -cleanup=2s only to show reliably the bug; once the fix is committed,
  51. // we can use -cleanup=50ms to speed the test up
  52. "child intercepts sigint: receives sigint and does cleanup": {
  53. args: []string{task, "--", SLEEPIT, "handle", "-sleep=10s", "-cleanup=2s"},
  54. sendSigs: 1,
  55. want: []string{
  56. "sleepit: ready\n",
  57. "sleepit: work started\n",
  58. "task: Signal received: \"interrupt\"\n",
  59. "sleepit: got signal=interrupt count=1\n",
  60. "sleepit: work canceled\n",
  61. "sleepit: cleanup started\n",
  62. "sleepit: cleanup done\n",
  63. "task: Failed to run task \"default\": exit status 3\n",
  64. },
  65. notWant: []string{
  66. "sleepit: got signal=interrupt count=2\n",
  67. "task: Failed to run task \"default\": context canceled\n",
  68. },
  69. },
  70. // regression: child receives 2 signal instead of 1 and thus terminates abruptly
  71. "child simulates terraform: receives 1 sigint and does cleanup": {
  72. args: []string{task, "--", SLEEPIT, "handle", "-term-after=2", "-sleep=10s", "-cleanup=50ms"},
  73. sendSigs: 1,
  74. want: []string{
  75. "sleepit: ready\n",
  76. "sleepit: work started\n",
  77. "task: Signal received: \"interrupt\"\n",
  78. "sleepit: got signal=interrupt count=1\n",
  79. "sleepit: work canceled\n",
  80. "sleepit: cleanup started\n",
  81. "sleepit: cleanup done\n",
  82. "task: Failed to run task \"default\": exit status 3\n",
  83. },
  84. notWant: []string{
  85. "sleepit: got signal=interrupt count=2\n",
  86. "sleepit: cleanup canceled\n",
  87. "task: Failed to run task \"default\": exit status 4\n",
  88. },
  89. },
  90. }
  91. for name, tc := range testCases {
  92. t.Run(name, func(t *testing.T) {
  93. var out bytes.Buffer
  94. sut := exec.Command(tc.args[0], tc.args[1:]...)
  95. sut.Stdout = &out
  96. sut.Stderr = &out
  97. sut.Dir = "testdata/ignore_signals"
  98. // Create a new process group by setting the process group ID of the child
  99. // to the child PID.
  100. // By default, the child would inherit the process group of the parent, but
  101. // we want to avoid this, to protect the parent (the test process) from the
  102. // signal that this test will send. More info in the comments below for
  103. // syscall.Kill().
  104. sut.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pgid: 0}
  105. if err := sut.Start(); err != nil {
  106. t.Fatalf("starting the SUT process: %v", err)
  107. }
  108. // After the child is started, we want to avoid a race condition where we send
  109. // it a signal before it had time to setup its own signal handlers. Sleeping
  110. // is way too flaky, instead we parse the child output until we get a line
  111. // that we know is printed after the signal handlers are installed...
  112. ready := false
  113. timeout := time.Duration(time.Second)
  114. start := time.Now()
  115. for time.Since(start) < timeout {
  116. if strings.Contains(out.String(), "sleepit: ready\n") {
  117. ready = true
  118. break
  119. }
  120. time.Sleep(10 * time.Millisecond)
  121. }
  122. if !ready {
  123. t.Fatalf("sleepit not ready after %v\n"+
  124. "additional information:\n"+
  125. " output:\n%s",
  126. timeout, out.String())
  127. }
  128. // When we have a running program in a shell and type CTRL-C, the tty driver
  129. // will send a SIGINT signal to all the processes in the foreground process
  130. // group (see https://en.wikipedia.org/wiki/Process_group).
  131. //
  132. // Here we want to emulate this behavior: send SIGINT to the process group of
  133. // the test executable. Although Go for some reasons doesn't wrap the
  134. // killpg(2) system call, what works is using syscall.Kill(-PID, SIGINT),
  135. // where the negative PID means the corresponding process group. Note that
  136. // this negative PID works only as long as the caller of the kill(2) system
  137. // call has a different PID, which is the case for this test.
  138. for range tc.sendSigs - 1 {
  139. if err := syscall.Kill(-sut.Process.Pid, syscall.SIGINT); err != nil {
  140. t.Fatalf("sending INT signal to the process group: %v", err)
  141. }
  142. time.Sleep(1 * time.Millisecond)
  143. }
  144. err := sut.Wait()
  145. var wantErr *exec.ExitError
  146. const wantExitStatus = 201
  147. if errors.As(err, &wantErr) {
  148. if wantErr.ExitCode() != wantExitStatus {
  149. t.Errorf(
  150. "waiting for child process: got exit status %v; want %d\n"+
  151. "additional information:\n"+
  152. " process state: %q",
  153. wantErr.ExitCode(), wantExitStatus, wantErr.String())
  154. }
  155. } else {
  156. t.Errorf("waiting for child process: got unexpected error type %v (%T); want (%T)",
  157. err, err, wantErr)
  158. }
  159. gotLines := strings.SplitAfter(out.String(), "\n")
  160. notFound := listDifference(tc.want, gotLines)
  161. if len(notFound) > 0 {
  162. t.Errorf("\nwanted but not found:\n%v", notFound)
  163. }
  164. found := listIntersection(tc.notWant, gotLines)
  165. if len(found) > 0 {
  166. t.Errorf("\nunwanted but found:\n%v", found)
  167. }
  168. if len(notFound) > 0 || len(found) > 0 {
  169. t.Errorf("\noutput:\n%v", gotLines)
  170. }
  171. })
  172. }
  173. }
  174. func getTaskPath() (string, error) {
  175. if info, err := os.Stat("./bin/task"); err == nil {
  176. return info.Name(), nil
  177. }
  178. if path, err := exec.LookPath("task"); err == nil {
  179. return path, nil
  180. }
  181. return "", errors.New("task: \"task\" binary was not found!")
  182. }
  183. // Return the difference of the two lists: the elements that are present in the first
  184. // list, but not in the second one. The notion of presence is not with `=` but with
  185. // string.Contains(l2, l1).
  186. // FIXME this does not enforce ordering. We might want to support both.
  187. func listDifference(lines1, lines2 []string) []string {
  188. difference := []string{}
  189. for _, l1 := range lines1 {
  190. found := false
  191. for _, l2 := range lines2 {
  192. if strings.Contains(l2, l1) {
  193. found = true
  194. break
  195. }
  196. }
  197. if !found {
  198. difference = append(difference, l1)
  199. }
  200. }
  201. return difference
  202. }
  203. // Return the intersection of the two lists: the elements that are present in both lists.
  204. // The notion of presence is not with '=' but with string.Contains(l2, l1)
  205. // FIXME this does not enforce ordering. We might want to support both.
  206. func listIntersection(lines1, lines2 []string) []string {
  207. intersection := []string{}
  208. for _, l1 := range lines1 {
  209. for _, l2 := range lines2 {
  210. if strings.Contains(l2, l1) {
  211. intersection = append(intersection, l1)
  212. break
  213. }
  214. }
  215. }
  216. return intersection
  217. }