blob: 9ac427a660e36767800a7cad73bc823bfba1e97e [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package subprocess
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"go.fuchsia.dev/fuchsia/tools/lib/clock"
)
func TestRun(t *testing.T) {
ctx := context.Background()
t.Run("should execute a command", func(t *testing.T) {
r := Runner{
Env: []string{"FOO=bar"}, // Cover env var handling.
}
message := "Hello, World!"
command := []string{"echo", message}
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
if err := r.Run(ctx, command, RunOptions{Stdout: stdout, Stderr: stderr, Env: []string{"BAR=baz"}}); err != nil {
t.Fatal(err)
}
stdoutS := strings.TrimSpace(stdout.String())
if stdoutS != message {
t.Fatalf("Expected output %q, but got %q", message, stdoutS)
}
stderrS := strings.TrimSpace(stderr.String())
if stderrS != "" {
t.Fatalf("Expected empty stderr, but got %q", stderrS)
}
})
t.Run("should error if the context completes before the command", func(t *testing.T) {
ctx, cancel := context.WithCancel(ctx)
cancel()
r := Runner{}
command := []string{"sleep", "5"}
err := r.Run(ctx, command, RunOptions{})
if err == nil {
t.Fatal("Expected sleep command to terminate early but it completed")
} else if !errors.Is(err, ctx.Err()) {
t.Fatalf("Expected Run() to return a context error after cancelation but got: %s", err)
}
})
t.Run("should return an error if the command fails", func(t *testing.T) {
r := Runner{}
command := []string{"not_a_command_12345"}
err := r.Run(ctx, command, RunOptions{})
if err == nil {
t.Fatalf("Expected invalid command to fail but it succeeded: %s", err)
} else if !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("Expected Run() to return exec.ErrNotFound but got: %s", err)
}
})
t.Run("should respect dir in options", func(t *testing.T) {
script := writeScript(
t,
`#!/bin/bash
pwd`,
)
r := Runner{Dir: t.TempDir()}
dir := t.TempDir()
var stdout bytes.Buffer
if err := r.Run(ctx, []string{script}, RunOptions{Stdout: &stdout, Dir: dir}); err != nil {
t.Fatal(err)
}
pwd := strings.TrimSpace(stdout.String())
if pwd != dir {
t.Errorf("Wrong dir: %s != %s", pwd, dir)
}
})
t.Run("should set environment variables", func(t *testing.T) {
for _, v := range []string{"PARENT_VAR", "OVERRIDDEN_PARENT_VAR"} {
os.Setenv(v, "0")
defer os.Unsetenv(v)
}
r := Runner{
Env: []string{
"OVERRIDDEN_PARENT_VAR=1",
"RUNNER_VAR=1",
"OVERRIDDEN_RUNNER_VAR=1",
},
}
runEnv := []string{
"OVERRIDDEN_RUNNER_VAR=2",
"RUN_VAR=2",
}
expected := map[string]string{
"PARENT_VAR": "0",
"OVERRIDDEN_PARENT_VAR": "1",
"RUNNER_VAR": "1",
"OVERRIDDEN_RUNNER_VAR": "2",
"RUN_VAR": "2",
}
scriptLines := []string{"#!/bin/sh"}
// Only print the values of the variables we actually care about rather
// than running `env` so we don't need to deal with all the weird values
// that other variables might have.
for varName := range expected {
scriptLines = append(scriptLines, fmt.Sprintf("echo \"%s=$%s\"", varName, varName))
}
script := writeScript(
t,
strings.Join(scriptLines, "\n")+"\n",
)
var stdout bytes.Buffer
if err := r.Run(ctx, []string{script}, RunOptions{
Stdout: &stdout,
Env: runEnv,
}); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
vars := make(map[string]string)
for _, line := range lines {
k, v, ok := strings.Cut(line, "=")
if !ok {
t.Fatalf("Invalid line from script: %q", line)
}
vars[k] = v
}
for k, v := range expected {
if vars[k] != v {
t.Errorf("Wrong value for env var %s: got %q, wanted %q", k, vars[k], v)
}
}
})
t.Run("should wait for command to finish after sending SIGTERM", func(t *testing.T) {
// The script below will print `start` to signify that it's ready to handle
// SIGTERMs and SIGINTs and then run cleanup() when it receives the signal.
// By checking that `start` is printed before canceling the context and checking
// that `finished` is printed after, we can assert that the cleanup() function
// was run before the script exited.
script := writeScript(
t,
`#!/bin/bash
cleanup() {
echo "finished"; exit 1
}
trap cleanup TERM INT
echo "start"
while true;do :; done`,
)
r := Runner{}
command := []string{script}
stdoutReader, stdout := io.Pipe()
defer stdoutReader.Close()
defer stdout.Close()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
buf := make([]byte, 20)
// Wait for script to print `start` before calling cancel() to know
// that it's ready to handle the SIGTERM.
if _, err := stdoutReader.Read(buf); err != nil || !bytes.Contains(buf, []byte("start")) {
t.Errorf("Failed to read `start` from stdout: %s, got: %s", err, string(buf))
}
cancel()
// After sending the SIGTERM, check that the script ran cleanup() and
// printed `finished`.
buf = make([]byte, 20)
if _, err := stdoutReader.Read(buf); err != nil || !bytes.Contains(buf, []byte("finished")) {
t.Errorf("Failed to read `finished` from stdout: %s, got: %s", err, string(buf))
}
}()
if err := r.Run(ctx, command, RunOptions{Stdout: stdout, Stderr: stdout}); err == nil {
t.Errorf("Expected script to terminate early but it completed successfully")
} else {
if !errors.Is(err, context.Canceled) {
t.Errorf("Expected Run() to return context.Canceled but got: %s", err)
}
}
})
t.Run("should kill command if it doesn't terminate after sending SIGTERM", func(t *testing.T) {
if runtime.GOOS == "darwin" {
// Setting the pgid doesn't work on Mac OS, so this test
// will hang because it can't kill the sleep process.
// TODO(https://fxbug.dev/42167142): Enable if we can find a way to kill
// the child processes.
t.Skip("Skipping on Mac because setting pgid doesn't work")
}
// Random number between 10,000 and 20,000 seconds to make it more
// likely to be unique to each test run, so leaked `sleep` processes
// don't show up across test runs.
rand.Seed(time.Now().UTC().UnixNano())
sleepDuration := rand.Intn(10000) + 10000
// The script below will print `start` to signify that it's ready to handle
// SIGTERMs and SIGINTs and then run cleanup() when it receives the signal.
// However, since it starts a `sleep` process, it actually waits for the process
// to finish before entering cleanup(). This will test that the process group
// gets killed if it can't clean up and exit in time. In this test, `finished`
// should not be in the output because the process would have been killed before
// it could run cleanup().
script := writeScript(t, fmt.Sprintf(
`#!/bin/bash
cleanup() {
echo "finished"; exit 1
}
trap cleanup TERM INT
sleep %d`, sleepDuration),
)
stdoutReader, stdout := io.Pipe()
defer stdoutReader.Close()
defer stdout.Close()
fakeClock := clock.NewFakeClock()
ctx := clock.NewContext(ctx, fakeClock)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
// Wait for the script to start sleeping before canceling the context.
for {
cmd := exec.Command("pgrep", "-f", fmt.Sprintf("sleep %d", sleepDuration))
// pgrep returns an exit code of 1 if it fails to
// find anything, so ignore the error.
output, _ := cmd.CombinedOutput()
if len(output) != 0 {
t.Logf("pgrep: %s", output)
break
}
if ctx.Err() != nil {
return
}
}
cancel()
// Wait for After() to be called before advancing the clock.
<-fakeClock.AfterCalledChan()
fakeClock.Advance(cleanupGracePeriod + time.Second)
// The script should be killed before it reaches cleanup(), so `finished`
// should NOT have been printed to stdout. No need to check the err from
// Read() because we don't expect the script to print anything more to
// stdout, so it should block until the deferred stdout.Close() gets
// executed.
buf := make([]byte, 20)
stdoutReader.Read(buf)
if bytes.Contains(buf, []byte("finished")) {
t.Errorf("Expected script to be killed without doing cleanup")
}
}()
r := Runner{}
if err := r.Run(ctx, []string{script}, RunOptions{Stdout: stdout}); err == nil {
t.Errorf("Expected script to terminate early but it completed successfully")
} else {
if !errors.Is(err, context.Canceled) {
t.Errorf("Expected Run() to return context.Canceled but got: %s", err)
}
}
})
}
func writeScript(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "script.sh")
if err := os.WriteFile(path, []byte(contents), 0o755); err != nil {
t.Fatal(err)
}
return path
}