package runner
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// flakyCheckout fails its first n calls, the way a clone fails while a
// deploy restarts the forge, then checks out for real.
type flakyCheckout struct {
mu sync.Mutex
fails int
calls int
inner fakeCheckout
}
func (f *flakyCheckout) Checkout(ctx context.Context, dir, checkin string) error {
f.mu.Lock()
f.calls++
failing := f.calls <= f.fails
f.mu.Unlock()
if failing {
return errors.New("connection refused")
}
return f.inner.Checkout(ctx, dir, checkin)
}
// THE CHECKOUT IS RETRIED IN PLACE; THE COMMAND NEVER IS.
//
// Getting the tree retries inside the task; a command that fails is
// reported on its first run. Why: ci/quarry.json's _comment.
func TestTheCheckoutRetriesAndTheCommandRunsOnce(t *testing.T) {
ran := filepath.Join(t.TempDir(), "ran")
manifest := func(exit string) string {
return `{"tasks":{"t":["sh","-c","echo x >> ` + ran + `; exit ` + exit + `"]}}`
}
runs := func() int {
b, _ := os.ReadFile(ran)
return strings.Count(string(b), "x")
}
newRunner := func(co Checkout) *Runner {
r, err := New(Config{
Bridge: "http://127.0.0.1:1", Token: "WT", Tasks: []string{"t"},
WorkDir: t.TempDir(), Checkout: co, Logf: t.Logf,
CheckoutRetry: []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond},
})
if err != nil {
t.Fatal(err)
}
return r
}
ctx := context.Background()
co := &flakyCheckout{fails: 2, inner: fakeCheckout{map[string]string{"c1": manifest("0")}}}
if err := newRunner(co).execute(ctx, "task", "t", "c1", "", io.Discard, io.Discard); err != nil {
t.Errorf("FAIL: a checkout that failed twice then worked failed the task: %v", err)
}
if co.calls != 3 || runs() != 1 {
t.Errorf("FAIL: checkout calls = %d, command runs = %d; want 3 and 1", co.calls, runs())
}
co = &flakyCheckout{fails: 99, inner: fakeCheckout{map[string]string{"c1": manifest("0")}}}
err := newRunner(co).execute(ctx, "task", "t", "c1", "", io.Discard, io.Discard)
if err == nil || !strings.Contains(err.Error(), "checkout") || co.calls != 4 {
t.Errorf("FAIL: a checkout that never works: err %v after %d calls; want a checkout error after 4", err, co.calls)
}
co = &flakyCheckout{inner: fakeCheckout{map[string]string{"c2": manifest("3")}}}
if err := newRunner(co).execute(ctx, "task", "t", "c2", "", io.Discard, io.Discard); err == nil {
t.Error("FAIL: a failing command passed")
}
if co.calls != 1 || runs() != 2 {
t.Errorf("FAIL: a failing command: checkout calls = %d, total command runs = %d; want 1 and 2", co.calls, runs())
}
t.Log("ok: the checkout retries in place; a failing command runs once")
}
// A stop during a checkout retry's wait ends the task at once, as a
// checkout failure, without running the command.
func TestAStopDuringACheckoutRetryWaitEndsTheTask(t *testing.T) {
ran := filepath.Join(t.TempDir(), "ran")
co := &flakyCheckout{fails: 99, inner: fakeCheckout{map[string]string{
"c1": `{"tasks":{"t":["sh","-c","echo x >> ` + ran + `"]}}`,
}}}
r, err := New(Config{
Bridge: "http://127.0.0.1:1", Token: "WT", Tasks: []string{"t"},
WorkDir: t.TempDir(), Checkout: co, Logf: t.Logf,
CheckoutRetry: []time.Duration{time.Hour},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(50*time.Millisecond, cancel)
done := make(chan error, 1)
go func() { done <- r.execute(ctx, "task", "t", "c1", "", io.Discard, io.Discard) }()
select {
case err := <-done:
if err == nil || !strings.Contains(err.Error(), "checkout") {
t.Errorf("FAIL: a stop mid-wait returned %v; want a checkout error", err)
}
case <-time.After(10 * time.Second):
t.Fatal("FAIL: a stop mid-wait did not end the task")
}
if _, err := os.Stat(ran); !os.IsNotExist(err) {
t.Error("FAIL: the command ran after the stop")
}
if co.calls != 1 {
t.Errorf("FAIL: checkout calls = %d, want 1", co.calls)
}
t.Log("ok: a stop during the wait ends the task as a checkout failure")
}