From 1ea1d040b365b80c4891319bad832501fd233e06 Mon Sep 17 00:00:00 2001 From: Elvis Moreta Date: Tue, 24 Jun 2025 17:11:56 -0500 Subject: [PATCH 1/2] feat: add failfast support for parallel tasks --- cmd/task/task.go | 7 +++++ internal/flags/flags.go | 3 ++ task.go | 51 ++++++++++++++++++++++++------- taskfile/ast/task.go | 4 +++ variables.go | 1 + website/docs/reference/cli.mdx | 3 +- website/docs/reference/schema.mdx | 3 +- 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/cmd/task/task.go b/cmd/task/task.go index d5f2b7baf0..acda98f8d3 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -118,6 +118,13 @@ func run() error { return err } + tf := e.Taskfile + if flags.FailFast && tf != nil && tf.Tasks != nil { + for t := range tf.Tasks.Values(nil) { + t.FailFast = true + } + } + if flags.ClearCache { cachePath := filepath.Join(e.TempDir.Remote, "remote") return os.RemoveAll(cachePath) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index dab9fdf8f4..1418140828 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -75,6 +75,7 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration + FailFast bool ) func init() { @@ -156,6 +157,8 @@ func init() { pflag.DurationVar(&CacheExpiryDuration, "expiry", 0, "Expiry duration for cached remote Taskfiles.") } + pflag.BoolVar(&FailFast, "failfast", false, "Run parallel deps to completion but still exit non-zero if any failed.") + pflag.Parse() } diff --git a/task.go b/task.go index 0b762c6140..fb4e52fc7a 100644 --- a/task.go +++ b/task.go @@ -6,6 +6,7 @@ import ( "os" "runtime" "slices" + "sync" "sync/atomic" "golang.org/x/sync/errgroup" @@ -259,23 +260,51 @@ func (e *Executor) mkdir(t *ast.Task) error { } func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { - g, ctx := errgroup.WithContext(ctx) - reacquire := e.releaseConcurrencyLimit() defer reacquire() - for _, d := range t.Deps { - d := d - g.Go(func() error { + if !t.FailFast { + g, ctx := errgroup.WithContext(ctx) + for _, d := range t.Deps { + d := d + g.Go(func() error { + return e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) + }) + } + return g.Wait() + } + + type depResult struct { + idx int + err error + } + + results := make(chan depResult, len(t.Deps)) + var wg sync.WaitGroup + wg.Add(len(t.Deps)) + + for i, d := range t.Deps { + i, d := i, d + go func() { + defer wg.Done() err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) - if err != nil { - return err - } - return nil - }) + results <- depResult{idx: i, err: err} + }() } - return g.Wait() + wg.Wait() + close(results) + + var firstErr error + for res := range results { + if res.err != nil && firstErr == nil { + firstErr = res.err + } + } + if firstErr != nil { + return fmt.Errorf("one or more dependencies failed: %w", firstErr) + } + return nil } func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, deferredExitCode *uint8) { diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 17fa976ae5..b0c8deeaa8 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -46,6 +46,7 @@ type Task struct { Namespace string IncludeVars *Vars IncludedTaskfileVars *Vars + FailFast bool } func (t *Task) Name() string { @@ -138,6 +139,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { Platforms []*Platform Requires *Requires Watch bool + FailFast bool `yaml:"failfast"` } if err := node.Decode(&task); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -176,6 +178,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.Platforms = task.Platforms t.Requires = task.Requires t.Watch = task.Watch + t.FailFast = task.FailFast return nil } @@ -220,6 +223,7 @@ func (t *Task) DeepCopy() *Task { Location: t.Location.DeepCopy(), Requires: t.Requires.DeepCopy(), Namespace: t.Namespace, + FailFast: t.FailFast, } return c } diff --git a/variables.go b/variables.go index 261de59b7e..a6e171885a 100644 --- a/variables.go +++ b/variables.go @@ -76,6 +76,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err Requires: origTask.Requires, Watch: origTask.Watch, Namespace: origTask.Namespace, + FailFast: origTask.FailFast, } new.Dir, err = execext.ExpandLiteral(new.Dir) if err != nil { diff --git a/website/docs/reference/cli.mdx b/website/docs/reference/cli.mdx index 55ab128f6e..fd20c53e6f 100644 --- a/website/docs/reference/cli.mdx +++ b/website/docs/reference/cli.mdx @@ -26,7 +26,8 @@ If `--` is given, all remaining arguments will be assigned to a special | `-C` | `--concurrency` | `int` | `0` | Limit number tasks to run concurrently. Zero means unlimited. | | `-d` | `--dir` | `string` | Working directory | Sets the directory in which Task will execute and look for a Taskfile. | | `-n` | `--dry` | `bool` | `false` | Compiles and prints tasks in the order that they would be run, without executing them. | -| `-x` | `--exit-code` | `bool` | `false` | Pass-through the exit code of the task command. | +| `-x` | `--exit-code` | `bool` | `false` | Pass-through the exit code of the task command. +| | `--failfast` | `bool` | `false` | Run parallel deps to completion but still exit non-zero if any failed. | | `-f` | `--force` | `bool` | `false` | Forces execution even when the task is up-to-date. | | `-g` | `--global` | `bool` | `false` | Runs global Taskfile, from `$HOME/Taskfile.{yml,yaml}`. | | `-h` | `--help` | `bool` | `false` | Shows Task usage. | diff --git a/website/docs/reference/schema.mdx b/website/docs/reference/schema.mdx index 8339f5100e..ad48df6565 100644 --- a/website/docs/reference/schema.mdx +++ b/website/docs/reference/schema.mdx @@ -110,7 +110,8 @@ vars: | `run` | `string` | The one declared globally in the Taskfile or `always` | Specifies whether the task should run again or not if called more than once. Available options: `always`, `once` and `when_changed`. | | `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/master/src/internal/syslist/syslist.go). Task will be skipped otherwise. | | `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | -| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | +| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). +| `failfast` | `bool` | `false` | Run parallel deps to completion but still exit non-zero if any failed. | :::info From 239e9142b4c249ab81d2047098d521382eebaf1a Mon Sep 17 00:00:00 2001 From: Elvis Moreta Date: Thu, 3 Jul 2025 12:26:03 -0500 Subject: [PATCH 2/2] fix: correct inverted failfast logic --- task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/task.go b/task.go index fb4e52fc7a..5a60246131 100644 --- a/task.go +++ b/task.go @@ -263,7 +263,7 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { reacquire := e.releaseConcurrencyLimit() defer reacquire() - if !t.FailFast { + if t.FailFast { g, ctx := errgroup.WithContext(ctx) for _, d := range t.Deps { d := d