diff --git a/CHANGELOG.md b/CHANGELOG.md index b01bf2121a..8073058012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- Added a `--max-runs` flag, which sets the maximum number of times a task + should run before being considered an infinite loop or a cyclic dep, + and therefore killed. +- Added a test task in `testdata/for` and test case to test `--max-runs` + behavior. +- Fixed a bug where `TaskCalledTooManyTimes` error always shows 0 as number + of exceeded max runs. +- Fixed a bug where a task will be one run short from the number specified + by`MaximumTaskCall`. + ## v3.30.0 - 2023-09-13 - Prep work for Remote Taskfiles (#1316 by @pd93). diff --git a/cmd/task/task.go b/cmd/task/task.go index b424bfacd7..758383a425 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -74,6 +74,7 @@ var flags struct { experiments bool download bool offline bool + maxRuns int } func main() { @@ -135,6 +136,7 @@ func run() error { pflag.DurationVarP(&flags.interval, "interval", "I", 0, "Interval to watch for changes.") pflag.BoolVarP(&flags.global, "global", "g", false, "Runs global Taskfile, from $HOME/{T,t}askfile.{yml,yaml}.") pflag.BoolVar(&flags.experiments, "experiments", false, "Lists all the available experiments and whether or not they are enabled.") + pflag.IntVar(&flags.maxRuns, "max-runs", task.MaximumTaskCall, "Maximum number of runs per task before being considered infinte loop or cyclic dep and therefore terminated.") // Gentle force experiment will override the force flag and add a new force-all flag if experiments.GentleForce { @@ -227,6 +229,10 @@ func run() error { taskSorter = &sort.AlphaNumeric{} } + if flags.maxRuns < 1 { + return errors.New("task: You can't set --max-runs to less than 1") + } + e := task.Executor{ Force: flags.force, ForceAll: flags.forceAll, @@ -245,6 +251,7 @@ func run() error { Color: flags.color, Concurrency: flags.concurrency, Interval: flags.interval, + MaxRuns: flags.maxRuns, Stdin: os.Stdin, Stdout: os.Stdout, diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index e36d21b9eb..ae531d6be6 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -51,6 +51,7 @@ If `--` is given, all remaining arguments will be assigned to a special | `-v` | `--verbose` | `bool` | `false` | Enables verbose mode. | | | `--version` | `bool` | `false` | Show Task version. | | `-w` | `--watch` | `bool` | `false` | Enables watch of the given task. | +| | `--max-runs` | `int` | `100` | Maximum number of runs per task before being considered infinte loop or cyclic dep and therefore terminated. | ## Exit Codes diff --git a/task.go b/task.go index b02eceb303..7a8be20df4 100644 --- a/task.go +++ b/task.go @@ -33,7 +33,8 @@ import ( const ( // MaximumTaskCall is the max number of times a task can be called. - // This exists to prevent infinite loops on cyclic dependencies + // This exists to prevent infinite loops on cyclic dependencies. + // Used as the default value if max-runs flag is not set. MaximumTaskCall = 100 ) @@ -64,6 +65,7 @@ type Executor struct { Color bool Concurrency int Interval time.Duration + MaxRuns int AssumesTerm bool Stdin io.Reader @@ -127,6 +129,11 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { return e.watchTasks(calls...) } + // if executor wasn't created through CLI, (i.e. for testing) + if e.MaxRuns == 0 { + e.MaxRuns = MaximumTaskCall + } + g, ctx := errgroup.WithContext(ctx) for _, c := range calls { c := c @@ -147,8 +154,8 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { if err != nil { return err } - if !e.Watch && atomic.AddInt32(e.taskCallCount[t.Task], 1) >= MaximumTaskCall { - return &errors.TaskCalledTooManyTimesError{TaskName: t.Task} + if !e.Watch && atomic.AddInt32(e.taskCallCount[t.Task], 1) > int32(e.MaxRuns) { + return &errors.TaskCalledTooManyTimesError{TaskName: t.Task, MaximumTaskCall: e.MaxRuns} } release := e.acquireConcurrencyLimit() diff --git a/task_test.go b/task_test.go index 8370afce59..61bf660a3a 100644 --- a/task_test.go +++ b/task_test.go @@ -2289,3 +2289,32 @@ func TestFor(t *testing.T) { }) } } + +func TestTooManyRuns(t *testing.T) { + tests := []struct { + name string + expectedError string + }{ + { + name: "loop-too-many", + expectedError: `task: Failed to run task "loop-too-many": task: Maximum task call exceeded (4) for task "looped-task": probably an cyclic dep or infinite loop`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var buff bytes.Buffer + e := task.Executor{ + Dir: "testdata/for", + Stdout: &buff, + Stderr: &buff, + Silent: true, + Force: true, + MaxRuns: 4, // task contains 5 loops + } + + require.NoError(t, e.Setup()) + assert.EqualError(t, e.Run(context.Background(), taskfile.Call{Task: test.name, Direct: true}), test.expectedError) + }) + } +} diff --git a/testdata/for/Taskfile.yml b/testdata/for/Taskfile.yml index 576f684dfa..9e2bc8a4da 100644 --- a/testdata/for/Taskfile.yml +++ b/testdata/for/Taskfile.yml @@ -76,6 +76,16 @@ tasks: var: FOO task: task-{{.ITEM}} + loop-too-many: + vars: + FOO: foo.txt foo.txt foo.txt foo.txt foo.txt + cmds: + - for: + var: FOO + task: looped-task + vars: + FILE: "{{.ITEM}}" + looped-task: internal: true cmd: cat "{{.FILE}}"