Repository: gotestyourself/gotestsum
Pull request #147 (2020-09-07 18:34:25)

Add a signal handler to forward SIGINT to 'go test'
Some test binaries may want to handle SIGINT to perform cleanup of
external resources. This signal handler will forward the interrupt
signal to the 'go test' process to give it a chance to cleanup before
shutting down.


Patch:
diff --git a/internal/signalhandlerdriver/main.go b/internal/signalhandlerdriver/main.go
new file mode 100644
index 0000000..cf2fd9f
--- /dev/null
+++ b/internal/signalhandlerdriver/main.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/signal"
+	"strconv"
+	"syscall"
+	"time"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		log("missing required filename argument")
+		os.Exit(1)
+	}
+
+	pid := []byte(strconv.Itoa(os.Getpid()))
+	if err := ioutil.WriteFile(os.Args[1], pid, 0644); err != nil {
+		log("failed to write file:", err.Error())
+		os.Exit(1)
+	}
+
+	c := make(chan os.Signal, 1)
+	signal.Notify(c)
+
+	var s os.Signal
+	select {
+	case s = <-c:
+	case <-time.After(time.Minute):
+		log("timeout waiting for signal")
+		os.Exit(1)
+	}
+
+	log("Received signal:", s)
+	switch n := s.(type) {
+	case syscall.Signal:
+		os.Exit(100 + int(n))
+	default:
+		log("failed to parse signal number")
+		os.Exit(3)
+	}
+}
+
+func log(v ...interface{}) {
+	fmt.Fprintln(os.Stderr, v...)
+}
diff --git a/main.go b/main.go
index 19f2231..023c79d 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"os/signal"
 	"strings"
 
 	"github.com/fatih/color"
@@ -370,6 +371,10 @@ func startGoTest(ctx context.Context, args []string) (proc, error) {
 		return p, errors.Wrapf(err, "failed to run %s", strings.Join(cmd.Args, " "))
 	}
 	log.Debugf("go test pid: %d", cmd.Process.Pid)
+
+	ctx, cancel := context.WithCancel(ctx)
+	newSignalHandler(ctx, cmd.Process.Pid)
+	p.cmd = &cancelWaiter{cancel: cancel, wrapped: p.cmd}
 	return p, nil
 }
 
@@ -390,3 +395,40 @@ func exitCodeWithDefault(err error) int {
 type exitCoder interface {
 	ExitCode() int
 }
+
+func newSignalHandler(ctx context.Context, pid int) {
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt)
+
+	go func() {
+		defer signal.Stop(c)
+
+		select {
+		case <-ctx.Done():
+			return
+		case s := <-c:
+			proc, err := os.FindProcess(pid)
+			if err != nil {
+				log.Errorf("failed to find pid of 'go test': %v", err)
+				return
+			}
+			if err := proc.Signal(s); err != nil {
+				log.Errorf("failed to interrupt 'go test': %v", err)
+				return
+			}
+		}
+	}()
+}
+
+// cancelWaiter wraps a waiter to cancel the context after the wrapped
+// Wait exits.
+type cancelWaiter struct {
+	cancel  func()
+	wrapped waiter
+}
+
+func (w *cancelWaiter) Wait() error {
+	err := w.wrapped.Wait()
+	w.cancel()
+	return err
+}


Test patch:
diff --git a/internal/text/testing.go b/internal/text/testing.go
index 2a13901..521385d 100644
--- a/internal/text/testing.go
+++ b/internal/text/testing.go
@@ -35,7 +35,7 @@ func OpRemoveSummaryLineElapsedTime(line string) string {
 }
 
 func OpRemoveTestElapsedTime(line string) string {
-	if i := strings.Index(line, " (0.0"); i > 0 && i+8 == len(line) {
+	if i := strings.Index(line, " (0."); i > 0 && i+8 == len(line) {
 		return line[:i]
 	}
 	return line
diff --git a/main_e2e_test.go b/main_e2e_test.go
index 976221e..655c866 100644
--- a/main_e2e_test.go
+++ b/main_e2e_test.go
@@ -2,19 +2,31 @@ package main
 
 import (
 	"bytes"
+	"io/ioutil"
 	"os"
 	"path/filepath"
 	"runtime"
 	"strings"
+	"sync"
 	"testing"
+	"time"
 
 	"gotest.tools/gotestsum/internal/text"
 	"gotest.tools/v3/assert"
 	"gotest.tools/v3/env"
 	"gotest.tools/v3/fs"
 	"gotest.tools/v3/golden"
+	"gotest.tools/v3/icmd"
+	"gotest.tools/v3/poll"
+	"gotest.tools/v3/skip"
 )
 
+func TestMain(m *testing.M) {
+	code := m.Run()
+	binaryFixture.Cleanup()
+	os.Exit(code)
+}
+
 func TestE2E_RerunFails(t *testing.T) {
 	type testCase struct {
 		name        string
@@ -128,3 +140,78 @@ func isPreGo114(ver string) bool {
 	}
 	return false
 }
+
+var binaryFixture pkgFixtureFile
+
+type pkgFixtureFile struct {
+	filename string
+	once     sync.Once
+	cleanup  func()
+}
+
+func (p *pkgFixtureFile) Path() string {
+	return p.filename
+}
+
+func (p *pkgFixtureFile) Do(f func() string) {
+	p.once.Do(func() {
+		p.filename = f()
+		p.cleanup = func() {
+			os.RemoveAll(p.filename) // nolint: errcheck
+		}
+	})
+}
+
+func (p *pkgFixtureFile) Cleanup() {
+	if p.cleanup != nil {
+		p.cleanup()
+	}
+}
+
+// compileBinary once the first time this function is called. Subsequent calls
+// will return the path to the compiled binary. The binary is removed when all
+// the tests in this package have completed.
+func compileBinary(t *testing.T) string {
+	t.Helper()
+	if testing.Short() {
+		t.Skip("too slow for short run")
+	}
+
+	binaryFixture.Do(func() string {
+		tmpDir, err := ioutil.TempDir("", "gotestsum-binary")
+		assert.NilError(t, err)
+
+		path := filepath.Join(tmpDir, "gotestsum")
+		result := icmd.RunCommand("go", "build", "-o", path, ".")
+		result.Assert(t, icmd.Success)
+		return path
+	})
+
+	if binaryFixture.Path() == "" {
+		t.Skip("previous attempt to compile the binary failed")
+	}
+	return binaryFixture.Path()
+}
+
+func TestE2E_SignalHandler(t *testing.T) {
+	skip.If(t, runtime.GOOS == "windows", "test timeout waiting for pidfile")
+	bin := compileBinary(t)
+
+	tmpDir := fs.NewDir(t, t.Name())
+	defer tmpDir.Remove()
+
+	driver := tmpDir.Join("driver")
+	target := filepath.FromSlash("./internal/signalhandlerdriver/")
+	icmd.RunCommand("go", "build", "-o", driver, target).
+		Assert(t, icmd.Success)
+
+	pidFile := tmpDir.Join("pidfile")
+	args := []string{"--raw-command", "--", driver, pidFile}
+	result := icmd.StartCmd(icmd.Command(bin, args...))
+
+	poll.WaitOn(t, poll.FileExists(pidFile), poll.WithTimeout(time.Second))
+	assert.NilError(t, result.Cmd.Process.Signal(os.Interrupt))
+	icmd.WaitOnCmd(2*time.Second, result)
+
+	result.Assert(t, icmd.Expected{ExitCode: 102})
+}