From 8604964beafc35f4785226eaaedbd4fa9a697ae6 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 8 Sep 2026 21:43:36 +0200 Subject: [PATCH 1/2] reexec: update minimum go version to go1.24 Signed-off-by: Sebastiaan van Stijn --- reexec/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reexec/go.mod b/reexec/go.mod index 15e248a7..30ef9b2d 100644 --- a/reexec/go.mod +++ b/reexec/go.mod @@ -1,3 +1,3 @@ module github.com/moby/sys/reexec -go 1.20 +go 1.24 From 6ff8880ebcc6d3c1ea0ec3bc7d9553c9e1d1a028 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 2 Mar 2026 10:34:54 +0100 Subject: [PATCH 2/2] 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 --- reexec/reexectest/reexectest.go | 124 ++++++++++ reexec/reexectest/reexectest_test.go | 331 +++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 reexec/reexectest/reexectest.go create mode 100644 reexec/reexectest/reexectest_test.go diff --git a/reexec/reexectest/reexectest.go b/reexec/reexectest/reexectest.go new file mode 100644 index 00000000..1d779406 --- /dev/null +++ b/reexec/reexectest/reexectest.go @@ -0,0 +1,124 @@ +// Package reexectest provides helpers for subprocess tests that re-exec the +// current test binary. The child process is selected by setting argv0 to a +// deterministic token derived from (t.Name(), name), while -test.run is used +// to run only the current test or subtest. +// +// Typical usage: +// +// func TestSomething(t *testing.T) { +// if reexectest.Run(t, "child", func(t *testing.T) { +// // child branch +// }) { +// return +// } +// +// // parent branch +// cmd := reexectest.Command(t, "child", "arg1") +// out, err := cmd.CombinedOutput() +// if err != nil { +// t.Fatalf("child failed: %v\n%s", err, out) +// } +// } +// +// Arguments passed to [Command] or [CommandContext] are forwarded to the child +// unchanged. Arguments beginning with "-" are not interpreted as flags by the +// test binary and may be parsed by the child as needed. +package reexectest + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "regexp" + "strings" + "testing" + + "github.com/moby/sys/reexec" +) + +const argv0Prefix = "reexectest-" + +// argv0Token returns a short (16 hex chars) deterministic argv0 token +// based on the test's name to prevent collisions. +func argv0Token(t *testing.T, name string) string { + sum := sha256.Sum256([]byte(t.Name() + "\x00" + name)) + return argv0Prefix + hex.EncodeToString(sum[:8]) +} + +// Run runs f in the current process iff it is the matching child process for +// (t, name). It returns true if f ran (i.e., we are the child). +// +// When Run returns true, callers should return from the test to avoid running +// the parent branch in the child process. +func Run(t *testing.T, name string, f func(t *testing.T)) bool { + t.Helper() + + if os.Args[0] != argv0Token(t, name) { + return false + } + + // Validate the arguments injected by CommandContext. Arguments after "--" + // belong to the caller and are passed through unchanged. + if len(os.Args) < 3 || + !strings.HasPrefix(os.Args[1], "-test.run=") || + os.Args[2] != "--" { + t.Fatalf("unexpected reexec arguments: %q", os.Args) + } + + // Scrub the injected test arguments for the lifetime of the child test. + origArgs := os.Args + os.Args = append([]string{os.Args[0]}, os.Args[3:]...) + t.Cleanup(func() { + os.Args = origArgs + }) + + f(t) + return true +} + +// Command returns an [*exec.Cmd] configured to re-exec the current test binary +// as a subprocess for the given test and name. +// +// It is a convenience wrapper around [CommandContext] using [testing.T.Context] +// as context. +func Command(t *testing.T, name string, args ...string) *exec.Cmd { + return commandContext(t, t.Context(), name, args...) +} + +// CommandContext returns an [*exec.Cmd] configured to re-exec the current test +// binary as a subprocess for the given test and name. +// +// The child process is restricted to run only the current test or subtest +// via -test.run. Its argv[0] is set to a deterministic token derived from +// (t.Name(), name), which is used by [Run] to select the child execution path. +// +// The provided context controls cancellation of the subprocess in the same way +// as [exec.CommandContext]. +// +// On Linux, the returned command has [syscall.SysProcAttr.Pdeathsig] set to +// SIGTERM, so the child receives SIGTERM if the creating thread dies. Callers +// may modify SysProcAttr before starting the command. +// +// It is analogous to [exec.CommandContext], but targets the current test binary. +func CommandContext(t *testing.T, ctx context.Context, name string, args ...string) *exec.Cmd { + return commandContext(t, ctx, name, args...) +} + +func commandContext(t *testing.T, ctx context.Context, name string, args ...string) *exec.Cmd { + argv0 := argv0Token(t, name) + pattern := testRunPattern(t.Name()) + + cmd := reexec.CommandContext(ctx, argv0, "-test.run="+pattern, "--") + cmd.Args = append(cmd.Args, args...) + return cmd +} + +func testRunPattern(name string) string { + parts := strings.Split(name, "/") + for i := range parts { + parts[i] = "^" + regexp.QuoteMeta(parts[i]) + "$" + } + return strings.Join(parts, "/") +} diff --git a/reexec/reexectest/reexectest_test.go b/reexec/reexectest/reexectest_test.go new file mode 100644 index 00000000..99c0328a --- /dev/null +++ b/reexec/reexectest/reexectest_test.go @@ -0,0 +1,331 @@ +package reexectest_test + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/moby/sys/reexec/reexectest" +) + +// assertOutput verifies the output produced by a reexec command. The test +// binary may append harness output after PASS, for example when coverage is +// enabled, so only output before the PASS line is compared. +func assertOutput(t *testing.T, out []byte, expected string) { + t.Helper() + + got := string(out) + if before, _, ok := strings.Cut(got, "\nPASS\n"); ok { + got = before + } + got = strings.TrimSpace(got) + + if got != expected { + t.Errorf("output: got %q, want %q\nfull output:\n%s", got, expected, out) + } +} + +// TestAssertOutput verifies handling of test-harness output appended by the +// reexecuted test binary. +func TestAssertOutput(t *testing.T) { + tests := []struct { + name string + out string + want string + }{ + { + name: "plain", + out: `child output +PASS +`, + want: "child output", + }, + { + name: "coverage", + out: `child output +PASS +coverage: 31.7% of statements +`, + want: "child output", + }, + { + name: "multiline", + out: `first line of child output +second line of child output +PASS +`, + want: `first line of child output +second line of child output`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertOutput(t, []byte(tc.out), tc.want) + }) + } +} + +// TestRun verifies the basic reexec behavior, including environment, exit +// status, argument passing, and context handling. +func TestRun(t *testing.T) { + // Verify that the child inherits the configured environment and writes output. + t.Run("env-and-output", func(t *testing.T) { + const expected = "child-env-and-output-ok" + if reexectest.Run(t, "env-and-output", func(t *testing.T) { + if got := os.Getenv("REEXEC_TEST_HELLO"); got != "world" { + t.Fatalf("env REEXEC_TEST_HELLO: got %q, want %q", got, "world") + } + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "env-and-output") + cmd.Env = append(cmd.Environ(), "REEXEC_TEST_HELLO=world") + + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + + // Verify that the child process exit status is propagated to the parent. + t.Run("exit-code", func(t *testing.T) { + if reexectest.Run(t, "exit-code", func(t *testing.T) { + os.Exit(23) + }) { + return + } + + cmd := reexectest.Command(t, "exit-code") + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-nil error") + } + + var ee *exec.ExitError + if !errors.As(err, &ee) { + t.Fatalf("got %T, want *exec.ExitError", err) + } + if code := ee.ProcessState.ExitCode(); code != 23 { + t.Fatalf("exit code: got %d, want %d", code, 23) + } + }) + + // Verify that child arguments, including flag-like arguments, are passed through unchanged. + t.Run("args-passthrough", func(t *testing.T) { + const expected = "child-args-passthrough-ok" + if reexectest.Run(t, "args-passthrough", func(t *testing.T) { + want := []string{"hello", "-flag", "-test.run=bogus", "world"} + got := os.Args[1:] + if !reflect.DeepEqual(got, want) { + t.Fatalf("args: got %q, want %q (full os.Args=%q)", got, want, os.Args) + } + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "args-passthrough", "hello", "-flag", "-test.run=bogus", "world") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + + // Verify that flag-like child arguments can be parsed independently of testing flags. + t.Run("custom-flags", func(t *testing.T) { + const expected = "child-custom-flags-ok" + if reexectest.Run(t, "custom-flags", func(t *testing.T) { + flags := flag.NewFlagSet("child", flag.ContinueOnError) + value := flags.String("custom", "", "") + if err := flags.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + if *value != "hello" { + t.Fatalf("custom flag: got %q, want %q", *value, "hello") + } + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "custom-flags", "-custom=hello") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + + // Verify that CommandContext reexecs the child with the provided context. + t.Run("context", func(t *testing.T) { + const expected = "child-context-ok" + if reexectest.Run(t, "context", func(t *testing.T) { + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.CommandContext(t, t.Context(), "context") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + + // Verify that CommandContext honors cancellation before starting the child. + t.Run("context-cancel", func(t *testing.T) { + if reexectest.Run(t, "context-cancel", func(t *testing.T) { + t.Fatal("unexpected child execution") + }) { + return + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + cmd := reexectest.CommandContext(t, ctx, "context-cancel") + err := cmd.Run() + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want context.Canceled", err) + } + }) + + // Verify that name selects one of multiple reexec handlers in the same test. + t.Run("named-handlers", func(t *testing.T) { + const expected = "second-handler-ok" + + if reexectest.Run(t, "first", func(t *testing.T) { + t.Fatal("unexpected first handler") + }) { + return + } + if reexectest.Run(t, "second", func(t *testing.T) { + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "second") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + + // Verify that Run keeps the injected test arguments scrubbed until child + // cleanup callbacks have completed. + t.Run("cleanup-args", func(t *testing.T) { + if reexectest.Run(t, "cleanup-args", func(t *testing.T) { + want := []string{"hello"} + + t.Cleanup(func() { + if got := os.Args[1:]; !reflect.DeepEqual(got, want) { + t.Errorf("cleanup args: got %q, want %q", got, want) + } + }) + }) { + return + } + + cmd := reexectest.Command(t, "cleanup-args", "hello") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + }) +} + +// TestRunTopLevel verifies that reexec works when used directly from a +// top-level test. +func TestRunTopLevel(t *testing.T) { + const expected = "child-non-sub-test-ok" + if reexectest.Run(t, "non-sub-test", func(t *testing.T) { + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "non-sub-test") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) +} + +// runPatternSubtestName is shared by TestRunPattern and its canary test so an +// over-broad match of the top-level test name would otherwise select both. +const runPatternSubtestName = "child" + +// TestRunPattern verifies that each component of the generated -test.run +// pattern is matched exactly. TestRunPatternExtra is the corresponding canary +// that detects if this reexec child also selects a prefix-matching test. +func TestRunPattern(t *testing.T) { + t.Run(runPatternSubtestName, func(t *testing.T) { + const expected = "child-ok" + if reexectest.Run(t, runPatternSubtestName, func(t *testing.T) { + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, runPatternSubtestName) + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) +} + +// TestRunPatternExtra is the canary for TestRunPattern. It intentionally has +// the same subtest name and a prefix-matching top-level test name, and must not +// be selected by TestRunPattern's reexec child. +func TestRunPatternExtra(t *testing.T) { + t.Run(runPatternSubtestName, func(t *testing.T) { + if len(os.Args) >= 3 && os.Args[2] == "--" { + t.Fatal("unexpected selection by a reexec child") + } + }) +} + +// TestRunPatternSpecialNames verifies that test names containing regexp +// metacharacters and separators are handled correctly when constructing +// -test.run patterns. +func TestRunPatternSpecialNames(t *testing.T) { + for _, name := range []string{ + "regexp.*chars", + "slash/name", + } { + t.Run(name, func(t *testing.T) { + // Verify that the exact generated test name can be selected even when it + // contains characters that are significant to -test.run matching. + const expected = "child-ok" + if reexectest.Run(t, "child", func(t *testing.T) { + fmt.Println(expected) + }) { + return + } + + cmd := reexectest.Command(t, "child") + out, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("command failed: %v\n%s", err, out) + } + assertOutput(t, out, expected) + }) + } +}