diff --git a/errors/errors.go b/errors/errors.go index 70924c45f4..b60bec9807 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -78,3 +78,8 @@ func AsType[T error](err error) (T, bool) { func Unwrap(err error) error { return errors.Unwrap(err) } + +// Join wraps the standard errors.Join function so that we don't need to alias that package. +func Join(errs ...error) error { + return errors.Join(errs...) +} diff --git a/task.go b/task.go index 654b397e7c..7d344cbf90 100644 --- a/task.go +++ b/task.go @@ -309,15 +309,20 @@ func (e *Executor) mkdir(t *ast.Task) error { } func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { + failfast := e.Failfast || t.Failfast g := &errgroup.Group{} - if e.Failfast || t.Failfast { + if failfast { g, ctx = errgroup.WithContext(ctx) } reacquire := e.releaseConcurrencyLimit() defer reacquire() - for _, d := range t.Deps { + // Without failfast every dep runs to completion, but errgroup.Wait only + // surfaces the first error, so all dep failures are collected and joined. + // Each dep writes its own slot, keeping the message in declaration order. + errs := make([]error, len(t.Deps)) + for i, d := range t.Deps { g.Go(func() error { depCtx := ctx var timeout *errors.TaskTimeoutError @@ -330,13 +335,22 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) if err != nil && timedOut(depCtx, timeout) { - return timeout + err = timeout + } + if !failfast && err != nil { + errs[i] = err } return err }) } - return g.Wait() + if err := g.Wait(); err != nil { + if failfast { + return err + } + return errors.Join(errs...) + } + return nil } func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, deferredExitCode *uint8) { diff --git a/task_test.go b/task_test.go index 540e7ba8de..f1054fa187 100644 --- a/task_test.go +++ b/task_test.go @@ -982,6 +982,29 @@ func TestCyclicDep(t *testing.T) { assert.ErrorAs(t, err, &taskCalledTooManyTimesError) } +func TestParallelDepsReportAllFailures(t *testing.T) { + t.Parallel() + + const dir = "testdata/deps_fail" + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + ) + require.NoError(t, e.Setup()) + err := e.Run(t.Context(), &task.Call{Task: "default"}) + require.Error(t, err) + // Both failing deps must be surfaced, not just the first one, and in the + // order they are declared. + assert.Equal(t, + "task: Failed to run task \"default\": "+ + "task: Failed to run task \"fail-1\": exit status 1\n"+ + "task: Failed to run task \"fail-2\": exit status 1", + err.Error(), + ) +} + func TestTaskVersion(t *testing.T) { t.Parallel() diff --git a/testdata/deps_fail/Taskfile.yml b/testdata/deps_fail/Taskfile.yml new file mode 100644 index 0000000000..fa5bf6e95b --- /dev/null +++ b/testdata/deps_fail/Taskfile.yml @@ -0,0 +1,15 @@ +version: '3' + +tasks: + default: + deps: [fail-1, fail-2] + cmds: + - echo 'default' + + fail-1: + cmds: + - exit 1 + + fail-2: + cmds: + - exit 1