516 lines
15 KiB
Go
516 lines
15 KiB
Go
package process
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRunPassesArgumentsDirectoryAndEnvironment(t *testing.T) {
|
|
workdir := filepath.Join(t.TempDir(), "directory with spaces")
|
|
if err := os.Mkdir(workdir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
|
|
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
err := Run(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "success"},
|
|
Dir: workdir,
|
|
Env: map[string]string{
|
|
"RELEASEKIT_PROCESS_HELPER": "1",
|
|
"RELEASEKIT_PROCESS_OVERLAY": "child",
|
|
},
|
|
Stdout: &stdout,
|
|
Stderr: &stderr,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := fmt.Sprintf("%s|argument with spaces|child|inherited", workdir)
|
|
if stdout.String() != want {
|
|
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
|
}
|
|
if stderr.String() != "child stderr\n" {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), "child stderr\n")
|
|
}
|
|
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
|
|
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
|
|
}
|
|
}
|
|
|
|
func TestRunPreservesExitError(t *testing.T) {
|
|
err := Run(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
})
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
|
|
t.Fatalf("Run() error = %v, want exit code 7", err)
|
|
}
|
|
}
|
|
|
|
func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
|
|
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
|
|
var stdout bytes.Buffer
|
|
err := Run(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument", "success"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
UnsetEnv: []string{"RELEASEKIT_PROCESS_INHERITED"},
|
|
Stdout: &stdout,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := fmt.Sprintf("%s|argument||", mustGetwd(t))
|
|
if stdout.String() != want {
|
|
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
|
}
|
|
}
|
|
|
|
func TestRunDirectoryEnvironmentPreservesExplicitPWDSelection(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("directory-derived PWD is a POSIX behavior")
|
|
}
|
|
parent := mustGetwd(t)
|
|
t.Setenv("PWD", parent)
|
|
workdir := t.TempDir()
|
|
for _, test := range []struct {
|
|
name, override, want string
|
|
unset bool
|
|
present bool
|
|
}{
|
|
{name: "directory", want: workdir, present: true},
|
|
{name: "override", override: "selected PWD", want: "selected PWD", present: true},
|
|
{name: "removed", unset: true},
|
|
{name: "override after removal", unset: true, override: "selected PWD", want: "selected PWD", present: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
invocation := Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "pwd"},
|
|
Dir: workdir,
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
}
|
|
if test.override != "" {
|
|
invocation.Env["PWD"] = test.override
|
|
}
|
|
if test.unset {
|
|
invocation.UnsetEnv = []string{"PWD"}
|
|
}
|
|
var stdout bytes.Buffer
|
|
invocation.Stdout = &stdout
|
|
if err := Run(t.Context(), invocation); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := stdout.String(), fmt.Sprintf("%s|%t", test.want, test.present); got != want {
|
|
t.Fatalf("child PWD = %q, want %q", got, want)
|
|
}
|
|
if os.Getenv("PWD") != parent || mustGetwd(t) != parent {
|
|
t.Fatal("child invocation changed the parent's directory or PWD")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunReportsMissingExecutable(t *testing.T) {
|
|
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
|
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
|
t.Fatalf("Run() error = %v, want missing executable diagnostic", err)
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
started := make(chan struct{}, 1)
|
|
invocation := Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
Stdout: writerFunc(func([]byte) (int, error) {
|
|
select {
|
|
case started <- struct{}{}:
|
|
default:
|
|
}
|
|
return len("started\n"), nil
|
|
}),
|
|
}
|
|
|
|
result := make(chan error, 1)
|
|
go func() { result <- Run(ctx, invocation) }()
|
|
select {
|
|
case <-started:
|
|
cancel()
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("child did not start")
|
|
}
|
|
select {
|
|
case err := <-result:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context cancellation", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Run() did not return after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestRunBoundsInheritedOutputPipes(t *testing.T) {
|
|
for _, mode := range []string{"cancel-descendant", "exit-descendant"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
ready := filepath.Join(t.TempDir(), "descendant")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var output bytes.Buffer
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
result <- Run(ctx, Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", mode},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
|
Stdout: &output, Stderr: &output,
|
|
})
|
|
}()
|
|
deadline := time.After(10 * time.Second)
|
|
var pid int
|
|
for pid == 0 {
|
|
data, err := os.ReadFile(ready)
|
|
if err == nil {
|
|
pid, _ = strconv.Atoi(string(data))
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("descendant did not become ready")
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
if process, err := os.FindProcess(pid); err == nil {
|
|
_ = process.Kill()
|
|
}
|
|
})
|
|
if mode == "cancel-descendant" {
|
|
cancel()
|
|
}
|
|
select {
|
|
case err := <-result:
|
|
if mode == "cancel-descendant" && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled command error = %v", err)
|
|
}
|
|
if mode == "exit-descendant" && !errors.Is(err, exec.ErrWaitDelay) {
|
|
t.Fatalf("exited command error = %v, want pipe teardown error", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("command did not release inherited output pipes")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunDoesNotLimitHealthyCommandLifetime(t *testing.T) {
|
|
err := Run(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "slow-success"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("healthy command failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
|
|
workdir := filepath.Join(t.TempDir(), "piped directory")
|
|
if err := os.Mkdir(workdir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
|
|
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
|
|
var stderr bytes.Buffer
|
|
child, err := StartPiped(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "exchange"},
|
|
Dir: workdir,
|
|
Env: map[string]string{
|
|
"RELEASEKIT_PROCESS_HELPER": "1",
|
|
"RELEASEKIT_PROCESS_OVERLAY": "child",
|
|
},
|
|
Stderr: &stderr,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.WriteString(child.Stdin(), "request\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("read response before closing input: %v", err)
|
|
}
|
|
want := fmt.Sprintf("request|%s|argument with spaces|child|inherited\n", workdir)
|
|
if response != want {
|
|
t.Fatalf("piped response = %q, want %q", response, want)
|
|
}
|
|
if err := child.Stdin().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := child.Wait(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := stderr.String(); got != "child stderr\n" {
|
|
t.Fatalf("child stderr = %q, want %q", got, "child stderr\n")
|
|
}
|
|
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
|
|
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
|
|
}
|
|
}
|
|
|
|
func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
child, err := StartPiped(ctx, Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
|
if err != nil || line != "started\n" {
|
|
cancel()
|
|
_ = child.Stdin().Close()
|
|
_ = child.Stdout().Close()
|
|
_ = child.Wait()
|
|
t.Fatalf("readiness = %q, %v; want started signal", line, err)
|
|
}
|
|
cancel()
|
|
if err := child.Stdin().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := child.Stdout().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := child.Wait(); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Wait() error = %v, want context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestStartPipedCancellationInterruptsInheritedOutputDrain(t *testing.T) {
|
|
ready := filepath.Join(t.TempDir(), "descendant")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
child, err := StartPiped(ctx, Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit-descendant"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1", "RELEASEKIT_DESCENDANT_READY": ready},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = child.Stdin().Close()
|
|
drained := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.Copy(io.Discard, child.Stdout())
|
|
drained <- err
|
|
}()
|
|
deadline := time.After(10 * time.Second)
|
|
var pid int
|
|
for pid == 0 {
|
|
if data, err := os.ReadFile(ready); err == nil {
|
|
pid, _ = strconv.Atoi(string(data))
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("descendant did not become ready")
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
if descendant, err := os.FindProcess(pid); err == nil {
|
|
_ = descendant.Kill()
|
|
}
|
|
})
|
|
cancel()
|
|
select {
|
|
case <-drained:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("inherited stdout did not close after cancellation")
|
|
}
|
|
if err := child.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("wait after cancellation: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
|
|
ctx := context.Background()
|
|
child, err := StartPiped(ctx, Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
|
|
if err != nil || line != "started\n" {
|
|
child.Cancel()
|
|
_ = child.Stdin().Close()
|
|
_ = child.Stdout().Close()
|
|
_ = child.Wait()
|
|
t.Fatalf("readiness = %q, %v; want started signal", line, err)
|
|
}
|
|
child.Cancel()
|
|
if err := child.Stdin().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := child.Stdout().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = child.Wait()
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("Wait() error = %v, want interrupted child exit", err)
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatalf("child cancellation changed parent context: %v", ctx.Err())
|
|
}
|
|
}
|
|
|
|
func TestStartPipedReportsStartFailure(t *testing.T) {
|
|
child, err := StartPiped(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
|
|
if err == nil || child != nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
|
|
t.Fatalf("StartPiped() = %#v, %v; want a missing executable error", child, err)
|
|
}
|
|
}
|
|
|
|
func TestStartPipedPreservesNonzeroExit(t *testing.T) {
|
|
child, err := StartPiped(context.Background(), Invocation{
|
|
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
|
|
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := child.Stdin().Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = child.Wait()
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
|
|
t.Fatalf("Wait() error = %v, want exit code 7", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessHelper(t *testing.T) {
|
|
if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" {
|
|
return
|
|
}
|
|
args := os.Args
|
|
if len(args) < 2 {
|
|
os.Exit(8)
|
|
}
|
|
mode := args[len(args)-1]
|
|
switch mode {
|
|
case "pwd":
|
|
value, present := os.LookupEnv("PWD")
|
|
fmt.Fprintf(os.Stdout, "%s|%t", value, present)
|
|
os.Exit(0)
|
|
case "success":
|
|
workdir, err := os.Getwd()
|
|
if err != nil {
|
|
os.Exit(9)
|
|
}
|
|
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s", workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
|
|
fmt.Fprintln(os.Stderr, "child stderr")
|
|
os.Exit(0)
|
|
case "exit":
|
|
os.Exit(7)
|
|
case "slow-success":
|
|
time.Sleep(1500 * time.Millisecond)
|
|
os.Exit(0)
|
|
case "wait":
|
|
fmt.Fprintln(os.Stdout, "started")
|
|
for {
|
|
time.Sleep(time.Second)
|
|
}
|
|
case "cancel-descendant", "exit-descendant":
|
|
child := exec.Command(os.Args[0], "-test.run=TestProcessHelper", "--", "unused", "descendant")
|
|
child.Stdout, child.Stderr = os.Stdout, os.Stderr
|
|
if err := child.Start(); err != nil {
|
|
os.Exit(14)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(os.Getenv("RELEASEKIT_DESCENDANT_READY")); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if mode == "exit-descendant" {
|
|
os.Exit(0)
|
|
}
|
|
for {
|
|
time.Sleep(time.Second)
|
|
}
|
|
case "descendant":
|
|
if err := os.WriteFile(os.Getenv("RELEASEKIT_DESCENDANT_READY"), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
|
os.Exit(15)
|
|
}
|
|
for {
|
|
time.Sleep(time.Second)
|
|
}
|
|
case "exchange":
|
|
reader := bufio.NewReader(os.Stdin)
|
|
request, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
os.Exit(11)
|
|
}
|
|
workdir, err := os.Getwd()
|
|
if err != nil {
|
|
os.Exit(12)
|
|
}
|
|
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s\n", strings.TrimSuffix(request, "\n"), workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
|
|
fmt.Fprintln(os.Stderr, "child stderr")
|
|
if _, err := io.Copy(io.Discard, reader); err != nil {
|
|
os.Exit(13)
|
|
}
|
|
default:
|
|
os.Exit(10)
|
|
}
|
|
}
|
|
|
|
type writerFunc func([]byte) (int, error)
|
|
|
|
func (write writerFunc) Write(p []byte) (int, error) {
|
|
return write(p)
|
|
}
|
|
|
|
func helperExecutable(t *testing.T) string {
|
|
t.Helper()
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return executable
|
|
}
|
|
|
|
func mustGetwd(t *testing.T) string {
|
|
t.Helper()
|
|
workdir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return workdir
|
|
}
|