Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7989f73

Browse files
andreyneringmarco-m-pix4daliculPix4D
committedJun 12, 2022
Fix behavior of interrupt (SIGINT, SIGTERM) signals
Task will now give time for the processes running to do cleanup work Ref #458 Ref #479 Fixes #728 Co-authored-by: Marco Molteni <[email protected]> Co-authored-by: aliculPix4D <[email protected]>
1 parent c9a582f commit 7989f73

File tree

9 files changed

+480
-18
lines changed

9 files changed

+480
-18
lines changed
 

‎.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dist/
2626
# exuberant ctags
2727
tags
2828

29-
/bin
29+
/bin/*
30+
!/bin/.keep
3031
/testdata/vars/v1
3132
/tmp

‎CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Fix behavior of interrupt (SIGINT, SIGTERM) signals. Task will now give time
6+
for the processes running to do cleanup work
7+
([#458](https://github.com/go-task/task/issues/458), [#479](https://github.com/go-task/task/pull/479), [#728](https://github.com/go-task/task/issues/728)).
58
- Add new `--exit-code` (`-x`) flag that will pass-through the exit form the
69
command being ran
710
([#755](https://github.com/go-task/task/pull/755)).

‎Taskfile.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ tasks:
2323

2424
install:
2525
desc: Installs Task
26+
sources:
27+
- './**/*.go'
2628
cmds:
2729
- go install -v -ldflags="-w -s -X main.version={{.GIT_COMMIT}}" ./cmd/task
2830

@@ -40,12 +42,30 @@ tasks:
4042

4143
lint:
4244
desc: Runs golangci-lint
45+
sources:
46+
- './**/*.go'
4347
cmds:
4448
- golangci-lint run
4549

50+
sleepit:build:
51+
desc: Builds the sleepit test helper
52+
sources:
53+
- ./cmd/sleepit/**/*.go
54+
generates:
55+
- ./bin/sleepit
56+
cmds:
57+
- go build -o ./bin/sleepit{{exeExt}} ./cmd/sleepit
58+
59+
sleepit:run:
60+
desc: Builds the sleepit test helper
61+
deps: [sleepit:build]
62+
cmds:
63+
- ./bin/sleepit {{.CLI_ARGS}}
64+
silent: true
65+
4666
test:
4767
desc: Runs test suite
48-
deps: [install]
68+
deps: [install, sleepit:build]
4969
cmds:
5070
- go test {{catLines .GO_PACKAGES}}
5171

‎bin/.keep

Whitespace-only changes.

‎cmd/sleepit/sleepit.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// This code is released under the MIT License
2+
// Copyright (c) 2020 Marco Molteni and the timeit contributors.
3+
4+
package main
5+
6+
import (
7+
"flag"
8+
"fmt"
9+
"os"
10+
"os/signal"
11+
"time"
12+
)
13+
14+
const usage = `sleepit: sleep for the specified duration, optionally handling signals
15+
When the line "sleepit: ready" is printed, it means that it is safe to send signals to it
16+
Usage: sleepit <command> [<args>]
17+
Commands
18+
default Use default action: on reception of SIGINT terminate abruptly
19+
handle Handle signals: on reception of SIGINT perform cleanup before exiting
20+
version Show the sleepit version`
21+
22+
var (
23+
// Filled by the linker.
24+
fullVersion = "unknown" // example: v0.0.9-8-g941583d027-dirty
25+
)
26+
27+
func main() {
28+
os.Exit(run(os.Args[1:]))
29+
}
30+
31+
func run(args []string) int {
32+
if len(args) < 1 {
33+
fmt.Fprintln(os.Stderr, usage)
34+
return 2
35+
}
36+
37+
defaultCmd := flag.NewFlagSet("default", flag.ExitOnError)
38+
defaultSleep := defaultCmd.Duration("sleep", 5*time.Second, "Sleep duration")
39+
40+
handleCmd := flag.NewFlagSet("handle", flag.ExitOnError)
41+
handleSleep := handleCmd.Duration("sleep", 5*time.Second, "Sleep duration")
42+
handleCleanup := handleCmd.Duration("cleanup", 5*time.Second, "Cleanup duration")
43+
handleTermAfter := handleCmd.Int("term-after", 0,
44+
"Terminate immediately after `N` signals.\n"+
45+
"Default is to terminate only when the cleanup phase has completed.")
46+
47+
versionCmd := flag.NewFlagSet("version", flag.ExitOnError)
48+
49+
switch args[0] {
50+
51+
case "default":
52+
_ = defaultCmd.Parse(args[1:])
53+
if len(defaultCmd.Args()) > 0 {
54+
fmt.Fprintf(os.Stderr, "default: unexpected arguments: %v\n", defaultCmd.Args())
55+
return 2
56+
}
57+
return supervisor(*defaultSleep, 0, 0, nil)
58+
59+
case "handle":
60+
_ = handleCmd.Parse(args[1:])
61+
if *handleTermAfter == 1 {
62+
fmt.Fprintf(os.Stderr, "handle: term-after cannot be 1\n")
63+
return 2
64+
}
65+
if len(handleCmd.Args()) > 0 {
66+
fmt.Fprintf(os.Stderr, "handle: unexpected arguments: %v\n", handleCmd.Args())
67+
return 2
68+
}
69+
sigCh := make(chan os.Signal, 1)
70+
signal.Notify(sigCh, os.Interrupt) // Ctrl-C -> SIGINT
71+
return supervisor(*handleSleep, *handleCleanup, *handleTermAfter, sigCh)
72+
73+
case "version":
74+
_ = versionCmd.Parse(args[1:])
75+
if len(versionCmd.Args()) > 0 {
76+
fmt.Fprintf(os.Stderr, "version: unexpected arguments: %v\n", versionCmd.Args())
77+
return 2
78+
}
79+
fmt.Printf("sleepit version %s\n", fullVersion)
80+
return 0
81+
82+
default:
83+
fmt.Fprintln(os.Stderr, usage)
84+
return 2
85+
}
86+
}
87+
88+
func supervisor(
89+
sleep time.Duration,
90+
cleanup time.Duration,
91+
termAfter int,
92+
sigCh <-chan os.Signal,
93+
) int {
94+
fmt.Printf("sleepit: ready\n")
95+
fmt.Printf("sleepit: PID=%d sleep=%v cleanup=%v\n",
96+
os.Getpid(), sleep, cleanup)
97+
98+
cancelWork := make(chan struct{})
99+
workerDone := worker(cancelWork, sleep, "work")
100+
101+
cancelCleaner := make(chan struct{})
102+
var cleanerDone <-chan struct{}
103+
104+
sigCount := 0
105+
for {
106+
select {
107+
case sig := <-sigCh:
108+
sigCount++
109+
fmt.Printf("sleepit: got signal=%s count=%d\n", sig, sigCount)
110+
if sigCount == 1 {
111+
// since `cancelWork` is unbuffered, sending will be synchronous:
112+
// we are ensured that the worker has terminated before starting cleanup.
113+
// This is important in some real-life situations.
114+
cancelWork <- struct{}{}
115+
cleanerDone = worker(cancelCleaner, cleanup, "cleanup")
116+
}
117+
if sigCount == termAfter {
118+
cancelCleaner <- struct{}{}
119+
return 4
120+
}
121+
case <-workerDone:
122+
return 0
123+
case <-cleanerDone:
124+
return 3
125+
}
126+
}
127+
}
128+
129+
// Start a worker goroutine and return immediately a `workerDone` channel.
130+
// The goroutine will prepend its prints with the prefix `name`.
131+
// The goroutine will simulate some work and will terminate when one of the following
132+
// conditions happens:
133+
// 1. When `howlong` is elapsed. This case will be signaled on the `workerDone` channel.
134+
// 2. When something happens on channel `canceled`. Note that this simulates real-life,
135+
// so cancellation is not instantaneous: if the caller wants a synchronous cancel,
136+
// it should send a message; if instead it wants an asynchronous cancel, it should
137+
// close the channel.
138+
func worker(
139+
canceled <-chan struct{},
140+
howlong time.Duration,
141+
name string,
142+
) <-chan struct{} {
143+
workerDone := make(chan struct{})
144+
deadline := time.Now().Add(howlong)
145+
go func() {
146+
fmt.Printf("sleepit: %s started\n", name)
147+
for {
148+
select {
149+
case <-canceled:
150+
fmt.Printf("sleepit: %s canceled\n", name)
151+
return
152+
default:
153+
if doSomeWork(deadline) {
154+
fmt.Printf("sleepit: %s done\n", name) // <== NOTE THIS LINE
155+
workerDone <- struct{}{}
156+
return
157+
}
158+
}
159+
}
160+
}()
161+
return workerDone
162+
}
163+
164+
// Do some work and then return, so that the caller can decide wether to continue or not.
165+
// Return true when all work is done.
166+
func doSomeWork(deadline time.Time) bool {
167+
if time.Now().After(deadline) {
168+
return true
169+
}
170+
timeout := 100 * time.Millisecond
171+
time.Sleep(timeout)
172+
return false
173+
}

‎cmd/task/task.go

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import (
55
"fmt"
66
"log"
77
"os"
8-
"os/signal"
98
"path/filepath"
109
"runtime/debug"
1110
"strings"
12-
"syscall"
1311

1412
"github.com/spf13/pflag"
1513
"mvdan.cc/sh/v3/syntax"
@@ -204,11 +202,12 @@ func main() {
204202
globals.Set("CLI_ARGS", taskfile.Var{Static: cliArgs})
205203
e.Taskfile.Vars.Merge(globals)
206204

207-
ctx := context.Background()
208205
if !watch {
209-
ctx = getSignalContext()
206+
e.InterceptInterruptSignals()
210207
}
211208

209+
ctx := context.Background()
210+
212211
if status {
213212
if err := e.Status(ctx, calls...); err != nil {
214213
log.Fatal(err)
@@ -249,18 +248,6 @@ func getArgs() ([]string, string, error) {
249248
return args[:doubleDashPos], strings.Join(quotedCliArgs, " "), nil
250249
}
251250

252-
func getSignalContext() context.Context {
253-
ch := make(chan os.Signal, 1)
254-
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
255-
ctx, cancel := context.WithCancel(context.Background())
256-
go func() {
257-
sig := <-ch
258-
log.Printf("task: signal received: %s", sig)
259-
cancel()
260-
}()
261-
return ctx
262-
}
263-
264251
func getVersion() string {
265252
if version != "" {
266253
return version

‎internal/execext/exec.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"time"
1011

1112
"mvdan.cc/sh/v3/expand"
1213
"mvdan.cc/sh/v3/interp"
@@ -48,6 +49,7 @@ func RunCommand(ctx context.Context, opts *RunCommandOptions) error {
4849
r, err := interp.New(
4950
interp.Params("-e"),
5051
interp.Env(expand.ListEnviron(environ...)),
52+
interp.ExecHandler(interp.DefaultExecHandler(15*time.Second)),
5153
interp.OpenHandler(openHandler),
5254
interp.StdIO(opts.Stdin, opts.Stdout, opts.Stderr),
5355
dirOption(opts.Dir),

‎signals.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package task
2+
3+
import (
4+
"os"
5+
"os/signal"
6+
"syscall"
7+
8+
"github.com/go-task/task/v3/internal/logger"
9+
)
10+
11+
// NOTE(@andreynering): This function intercepts SIGINT and SIGTERM signals
12+
// so the Task process is not killed immediatelly and processes running have
13+
// time to do cleanup work.
14+
func (e *Executor) InterceptInterruptSignals() {
15+
ch := make(chan os.Signal, 3)
16+
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
17+
18+
go func() {
19+
for i := 1; i <= 3; i++ {
20+
sig := <-ch
21+
22+
if i < 3 {
23+
e.Logger.Outf(logger.Yellow, `task: Signal received: "%s"`, sig)
24+
continue
25+
}
26+
27+
e.Logger.Errf(logger.Red, `task: Signal received for the third time: "%s". Forcing shutdown`, sig)
28+
os.Exit(1)
29+
}
30+
}()
31+
}

‎unix_test.go

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

0 commit comments

Comments
 (0)
Please sign in to comment.