Skip to content

Commit fe0a892

Browse files
committed
reexec: add reexectest package
This package allows using the reexec functionality to execute child processes as part of a test. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
1 parent 7f6a0a1 commit fe0a892

File tree

4 files changed

+232
-0
lines changed

4 files changed

+232
-0
lines changed

reexec/reexectest/reexectest.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Package reexectest provides small helpers for subprocess tests that re-exec
2+
// the current test binary. The child process is selected by setting argv0 to a
3+
// deterministic token derived from (t.Name(), name), while -test.run is used to
4+
// run only the current test/subtest.
5+
//
6+
// Typical usage:
7+
//
8+
// func TestSomething(t *testing.T) {
9+
// if reexectest.Run(t, "child", func(t *testing.T) {
10+
// // child branch
11+
// }) {
12+
// return
13+
// }
14+
//
15+
// // parent branch
16+
// cmd := reexectest.CommandContext(t, t.Context(), "child", "arg1")
17+
// out, err := cmd.CombinedOutput()
18+
// if err != nil {
19+
// t.Fatalf("child failed: %v\n%s", err, out)
20+
// }
21+
// }
22+
package reexectest
23+
24+
import (
25+
"context"
26+
"crypto/sha256"
27+
"encoding/hex"
28+
"os"
29+
"os/exec"
30+
"regexp"
31+
"strings"
32+
"testing"
33+
)
34+
35+
const argv0Prefix = "reexectest-"
36+
37+
// argv0Token returns a short (16 hex chars) deterministic argv0 token
38+
// based on the test's name to prevent collisions.
39+
func argv0Token(t *testing.T, name string) string {
40+
sum := sha256.Sum256([]byte(t.Name() + "\x00" + name))
41+
return argv0Prefix + hex.EncodeToString(sum[:8])
42+
}
43+
44+
// Run runs f in the current process iff it is the matching child process for
45+
// (t, name). It returns true if f ran (i.e., we are the child).
46+
//
47+
// When Run returns true, callers should return from the test to avoid running
48+
// the parent branch in the child process.
49+
func Run(t *testing.T, name string, f func(t *testing.T)) bool {
50+
t.Helper()
51+
52+
if os.Args[0] != argv0Token(t, name) {
53+
return false
54+
}
55+
56+
// Scrub the "-test.run=<pattern>" that was injected by CommandContext
57+
origArgs := os.Args
58+
if len(os.Args) > 1 && strings.HasPrefix(os.Args[1], "-test.run=") {
59+
os.Args = append(os.Args[:1], os.Args[2:]...)
60+
defer func() { os.Args = origArgs }()
61+
}
62+
63+
f(t)
64+
return true
65+
}
66+
67+
// Command returns an [*exec.Cmd] configured to re-exec the current test binary
68+
// as a subprocess for the given test and name.
69+
//
70+
// The child process is restricted to run only the current test or subtest
71+
// (via -test.run). Its argv[0] is set to a deterministic token derived from
72+
// (t.Name(), name), which is used by [Run] to select the child execution path.
73+
//
74+
// On Linux, the returned command sets [syscall.SysProcAttr.Pdeathsig] to
75+
// SIGTERM, so the child receives SIGTERM if the creating thread dies.
76+
// Callers may modify SysProcAttr before starting the command.
77+
//
78+
// It is analogous to [exec.Command], but targets the current test binary.
79+
func Command(t *testing.T, name string, args ...string) *exec.Cmd {
80+
return commandContext(t, t.Context(), name, args...)
81+
}
82+
83+
// CommandContext is like [Command] but includes a context. It uses
84+
// [exec.CommandContext] under the hood.
85+
//
86+
// The provided context controls cancellation of the subprocess in the same
87+
// way as [exec.CommandContext].
88+
//
89+
// On Linux, the returned command sets [syscall.SysProcAttr.Pdeathsig] to
90+
// SIGTERM. Callers may modify SysProcAttr before starting the command.
91+
func CommandContext(t *testing.T, ctx context.Context, name string, args ...string) *exec.Cmd {
92+
return commandContext(t, ctx, name, args...)
93+
}
94+
95+
func commandContext(t *testing.T, ctx context.Context, name string, args ...string) *exec.Cmd {
96+
t.Helper()
97+
exe, err := os.Executable()
98+
if err != nil {
99+
t.Fatalf("os.Executable(): %v", err)
100+
}
101+
102+
argv0 := argv0Token(t, name)
103+
pattern := "^" + regexp.QuoteMeta(t.Name()) + "$"
104+
105+
cmd := exec.CommandContext(ctx, exe)
106+
cmd.Path = exe
107+
cmd.Args = append([]string{argv0, "-test.run=" + pattern}, args...)
108+
setPdeathsig(cmd)
109+
return cmd
110+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//go:build linux
2+
3+
package reexectest
4+
5+
import (
6+
"os/exec"
7+
"syscall"
8+
)
9+
10+
func setPdeathsig(cmd *exec.Cmd) {
11+
if cmd.SysProcAttr == nil {
12+
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
13+
}
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//go:build !linux
2+
3+
package reexectest
4+
5+
import "os/exec"
6+
7+
func setPdeathsig(*exec.Cmd) {}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package reexectest_test
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"reflect"
9+
"strings"
10+
"testing"
11+
12+
"github.com/moby/sys/reexec/reexectest"
13+
)
14+
15+
func TestRun(t *testing.T) {
16+
t.Run("env-and-output", func(t *testing.T) {
17+
const expected = "child-env-and-output-ok"
18+
if reexectest.Run(t, "env-and-output", func(t *testing.T) {
19+
if got := os.Getenv("REEXEC_TEST_HELLO"); got != "world" {
20+
t.Fatalf("env REEXEC_TEST_HELLO: got %q, want %q", got, "world")
21+
}
22+
fmt.Println(expected)
23+
}) {
24+
return
25+
}
26+
27+
cmd := reexectest.CommandContext(t, t.Context(), "env-and-output")
28+
cmd.Env = append(cmd.Environ(), "REEXEC_TEST_HELLO=world")
29+
30+
out, err := cmd.CombinedOutput()
31+
if err != nil {
32+
t.Errorf("env-and-output child failed: %v\n%s", err, out)
33+
}
34+
if got := strings.TrimSpace(strings.TrimSuffix(string(out), "PASS\n")); got != expected {
35+
t.Errorf("env-and-output output: got %q, want %q", got, expected)
36+
}
37+
})
38+
39+
t.Run("exit-code", func(t *testing.T) {
40+
if reexectest.Run(t, "exit-code", func(t *testing.T) {
41+
os.Exit(23)
42+
}) {
43+
return
44+
}
45+
46+
cmd := reexectest.CommandContext(t, t.Context(), "exit-code")
47+
err := cmd.Run()
48+
if err == nil {
49+
t.Fatalf("expected non-nil error")
50+
}
51+
52+
var ee *exec.ExitError
53+
if !errors.As(err, &ee) {
54+
t.Fatalf("got %T, want *exec.ExitError", err)
55+
}
56+
if code := ee.ProcessState.ExitCode(); code != 23 {
57+
t.Fatalf("exit code: got %d, want %d", code, 23)
58+
}
59+
})
60+
61+
t.Run("args-passthrough", func(t *testing.T) {
62+
const expected = "child-args-passthrough-ok"
63+
if reexectest.Run(t, "args-passthrough", func(t *testing.T) {
64+
want := []string{"hello", "world"}
65+
got := os.Args[1:]
66+
if !reflect.DeepEqual(got, want) {
67+
t.Fatalf("args: got %q, want %q (full os.Args=%q)", got, want, os.Args)
68+
}
69+
fmt.Println(expected)
70+
}) {
71+
return
72+
}
73+
74+
cmd := reexectest.CommandContext(t, t.Context(), "args-passthrough", "hello", "world")
75+
out, err := cmd.CombinedOutput()
76+
if err != nil {
77+
t.Errorf("args-passthrough child failed: %v\n%s", err, out)
78+
}
79+
if got := strings.TrimSpace(strings.TrimSuffix(string(out), "PASS\n")); got != expected {
80+
t.Errorf("args-passthrough output: got %q, want %q", got, expected)
81+
}
82+
})
83+
}
84+
85+
func TestRunNonSubtest(t *testing.T) {
86+
const expected = "child-non-sub-test-ok"
87+
if reexectest.Run(t, "non-sub-test", func(t *testing.T) {
88+
fmt.Println(expected)
89+
}) {
90+
return
91+
}
92+
93+
cmd := reexectest.CommandContext(t, t.Context(), "non-sub-test")
94+
out, err := cmd.CombinedOutput()
95+
if err != nil {
96+
t.Errorf("non-sub-test child failed: %v\n%s", err, out)
97+
}
98+
if got := strings.TrimSpace(strings.TrimSuffix(string(out), "PASS\n")); got != expected {
99+
t.Errorf("non-sub-test output: got %q, want %q", got, expected)
100+
}
101+
}

0 commit comments

Comments
 (0)