From 04b2cc68abb7fb7e750e90dc2e1a793ef5372e6f Mon Sep 17 00:00:00 2001 From: AP Date: Sat, 17 Oct 2026 10:02:49 +0530 Subject: [PATCH 1/2] fix: report all dependency failures, not just the first When dependencies run in parallel without failfast, every dep runs to completion, but errgroup.Wait only surfaced the first error - the rest were silently dropped. Collect each dep's failure and return them joined so a single run shows everything that went wrong. --- task.go | 24 +++++++++++++++++++++--- task_test.go | 19 +++++++++++++++++++ testdata/deps_fail/Taskfile.yml | 15 +++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 testdata/deps_fail/Taskfile.yml diff --git a/task.go b/task.go index 654b397e7c..f5b051528c 100644 --- a/task.go +++ b/task.go @@ -2,11 +2,13 @@ package task import ( "context" + stderrors "errors" "fmt" "os" "runtime" "slices" "strings" + "sync" "sync/atomic" "golang.org/x/sync/errgroup" @@ -309,14 +311,19 @@ 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() + // Without failfast every dep runs to completion, but errgroup.Wait only + // surfaces the first error, so all dep failures are collected and joined. + var mu sync.Mutex + var errs []error for _, d := range t.Deps { g.Go(func() error { depCtx := ctx @@ -330,13 +337,24 @@ 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 { + mu.Lock() + errs = append(errs, err) + mu.Unlock() } return err }) } - return g.Wait() + if err := g.Wait(); err != nil { + if failfast { + return err + } + return stderrors.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..1082e5d83c 100644 --- a/task_test.go +++ b/task_test.go @@ -982,6 +982,25 @@ 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) + msg := err.Error() + // Both failing deps must be surfaced, not just the first one. + assert.Contains(t, msg, `"fail-1"`) + assert.Contains(t, msg, `"fail-2"`) +} + 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 From f71c2fe0c0064520d13c84c8fdcc7c2cefc23171 Mon Sep 17 00:00:00 2001 From: AP Date: Sun, 1 Nov 2026 21:25:32 +0530 Subject: [PATCH 2/2] fix: report dep failures in declaration order Collect parallel dep errors by index instead of completion order, so the joined message is deterministic. Use the repo's errors package (with a new Join wrapper) instead of stdlib errors, which depguard forbids. Strengthen the test to assert the exact joined message. --- errors/errors.go | 5 +++++ task.go | 14 +++++--------- task_test.go | 12 ++++++++---- 3 files changed, 18 insertions(+), 13 deletions(-) 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 f5b051528c..7d344cbf90 100644 --- a/task.go +++ b/task.go @@ -2,13 +2,11 @@ package task import ( "context" - stderrors "errors" "fmt" "os" "runtime" "slices" "strings" - "sync" "sync/atomic" "golang.org/x/sync/errgroup" @@ -322,9 +320,9 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { // Without failfast every dep runs to completion, but errgroup.Wait only // surfaces the first error, so all dep failures are collected and joined. - var mu sync.Mutex - var errs []error - for _, d := range t.Deps { + // 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 @@ -340,9 +338,7 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { err = timeout } if !failfast && err != nil { - mu.Lock() - errs = append(errs, err) - mu.Unlock() + errs[i] = err } return err }) @@ -352,7 +348,7 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { if failfast { return err } - return stderrors.Join(errs...) + return errors.Join(errs...) } return nil } diff --git a/task_test.go b/task_test.go index 1082e5d83c..f1054fa187 100644 --- a/task_test.go +++ b/task_test.go @@ -995,10 +995,14 @@ func TestParallelDepsReportAllFailures(t *testing.T) { require.NoError(t, e.Setup()) err := e.Run(t.Context(), &task.Call{Task: "default"}) require.Error(t, err) - msg := err.Error() - // Both failing deps must be surfaced, not just the first one. - assert.Contains(t, msg, `"fail-1"`) - assert.Contains(t, msg, `"fail-2"`) + // 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) {