package runner
import (
"context"
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// EACH TASK GETS A TEMP DIRECTORY OF ITS OWN, REMOVED WHEN IT ENDS.
//
// A task killed before `go test` cleaned up left its go-build dirs in
// the host's tmpfs /tmp until the disk was full and NATS refused
// streams (#317). TMPDIR and GOTMPDIR now point into a per-task
// directory under the runner's own root, and that directory goes with
// the task however it ends.
func TestATasksTempDirIsRemovedWhenItEnds(t *testing.T) {
cases := []struct {
name string
run string
cancel bool
}{
{"success", "echo TEMPIS:$TMPDIR:GOTEMPIS:$GOTMPDIR:END; mkdir -p $GOTMPDIR/go-build1; echo x > $GOTMPDIR/go-build1/f", false},
{"failure", "echo TEMPIS:$TMPDIR:GOTEMPIS:$GOTMPDIR:END; echo x > $TMPDIR/f; exit 3", false},
{"cancel", "echo TEMPIS:$TMPDIR:GOTEMPIS:$GOTMPDIR:END; echo x > $TMPDIR/f; sleep 30", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
job, _ := json.Marshal(Job{Steps: []JobStep{{Run: c.run}}})
tk := task("t1", "c1")
tk.Metadata = map[string]string{JobKey: string(job)}
br := &fakeBridge{queue: map[string][]polled{"t-job": {tk}}, resolved: map[string]map[string]any{}, logs: map[string]string{},
cancelled: map[string]bool{}}
srv := httptest.NewServer(br.handler())
defer srv.Close()
root := filepath.Join(t.TempDir(), tempRootName)
r, err := New(Config{
Bridge: srv.URL, Token: "WT", Tasks: []string{"t-job"}, MaxTasks: 1,
WorkDir: t.TempDir(), TempRoot: root, Checkout: appCheckout{}, Heartbeat: 100 * time.Millisecond,
Logf: func(string, ...any) {},
})
if err != nil {
t.Fatal(err)
}
ctx, cancelRun := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { r.Run(ctx); close(done) }()
defer func() { cancelRun(); <-done }()
var tmp string
deadline := time.Now().Add(15 * time.Second)
for ; time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
br.mu.Lock()
l := br.logs["t1"]
br.mu.Unlock()
if _, rest, ok := strings.Cut(l, "\nTEMPIS:"); ok && strings.Contains(rest, ":END") {
tmp, _, _ = strings.Cut(rest, ":")
if c.cancel {
br.mu.Lock()
br.cancelled["t1"] = true
br.mu.Unlock()
}
break
}
}
if tmp == "" {
t.Fatal("FAIL: the job never reported its TMPDIR")
}
if filepath.Dir(tmp) != root {
t.Errorf("FAIL: TMPDIR %q is not directly inside the runner's root %q", tmp, root)
}
br.mu.Lock()
l := br.logs["t1"]
br.mu.Unlock()
if !strings.Contains(l, "GOTEMPIS:"+tmp+":END") {
t.Errorf("FAIL: GOTMPDIR is not TMPDIR %q in %q", tmp, l)
}
gone := false
for ; time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if _, err := os.Stat(tmp); os.IsNotExist(err) {
gone = true
break
}
}
if !gone {
t.Errorf("FAIL: %s still exists after the task ended", tmp)
}
if es, _ := os.ReadDir(root); len(es) != 0 {
t.Errorf("FAIL: the root holds %d entries after the task ended", len(es))
}
})
}
}
// A RUNNER CLEARS ITS OWN TASK DIRS FROM A PREVIOUS LIFE, AND NOTHING ELSE.
//
// Not a neighbour of the root, not a file in it that is no task's, and
// not the live dir of another runner sharing the work dir.
func TestARunnerStartSweepsItsOwnTaskDirsOnly(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, tempRootName)
mk := func(p string) string {
if err := os.MkdirAll(filepath.Join(p, "go-build9"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(p, "go-build9", "f"), []byte("x"), 0o644)
return p
}
deadPid := 1 << 22 // above any pid_max a test host sets
stale := mk(filepath.Join(root, taskPrefix+strconv.Itoa(deadPid)+"-1"))
ownOld := mk(filepath.Join(root, taskPrefix+strconv.Itoa(os.Getpid())+"-2"))
other := mk(filepath.Join(root, "go-build-not-a-task"))
otherFile := filepath.Join(root, "notes.txt")
os.WriteFile(otherFile, []byte("x"), 0o644)
neighbour := mk(filepath.Join(base, "go-build-neighbour"))
// A live other runner: its parent shell's pid is alive and not ours.
live := mk(filepath.Join(root, taskPrefix+strconv.Itoa(os.Getppid())+"-3"))
srv := httptest.NewServer((&fakeBridge{queue: map[string][]polled{}, resolved: map[string]map[string]any{}, logs: map[string]string{}}).handler())
defer srv.Close()
r, err := New(Config{
Bridge: srv.URL, Token: "WT", Tasks: []string{"t-job"}, MaxTasks: 1,
WorkDir: t.TempDir(), TempRoot: root, Checkout: appCheckout{}, Logf: func(string, ...any) {},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { r.Run(ctx); close(done) }()
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
if _, err := os.Stat(stale); os.IsNotExist(err) {
break
}
}
cancel()
<-done
for _, p := range []string{stale, ownOld} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("FAIL: %s survived the start-up sweep", p)
}
}
for _, p := range []string{other, otherFile, neighbour, live} {
if _, err := os.Stat(p); err != nil {
t.Errorf("FAIL: the sweep touched %s: %v", p, err)
}
}
}
// A TEMP ROOT THAT IS A SYMLINK IS REFUSED, UNTOUCHED.
func TestARunnerRefusesASymlinkedTempRoot(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "elsewhere")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
victim := filepath.Join(target, taskPrefix+"1-1")
os.MkdirAll(victim, 0o755)
root := filepath.Join(base, tempRootName)
if err := os.Symlink(target, root); err != nil {
t.Skip(err)
}
r, err := New(Config{
Bridge: "http://127.0.0.1:1", Token: "WT", Tasks: []string{"t-job"}, MaxTasks: 1,
WorkDir: t.TempDir(), TempRoot: root, Checkout: appCheckout{}, Logf: func(string, ...any) {},
})
if err != nil {
t.Fatal(err)
}
if err := r.Run(context.Background()); err == nil || err == context.Canceled {
t.Errorf("FAIL: Run = %v, want a refusal of the symlinked root", err)
}
if _, err := os.Stat(victim); err != nil {
t.Errorf("FAIL: a dir behind the symlink was touched: %v", err)
}
if info, _ := os.Stat(target); info.Mode().Perm() != 0o755 {
t.Errorf("FAIL: the symlink's target was chmodded to %v", info.Mode().Perm())
}
}