From dafbabda041c7fcedbe00b3b11f401c2e529d1f6 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 30 Jun 2026 10:29:20 +0200 Subject: [PATCH 1/3] feat: add command-level timeout support Add a per-command `timeout` option that terminates a command once it exceeds the given duration, preventing commands from hanging indefinitely in a pipeline. Uses Go duration syntax (e.g. 30s, 5m, 1h30m) and applies to both shell commands and task calls. Closes #1569 --- task.go | 12 ++++++ task_test.go | 57 ++++++++++++++++++++++++++++ taskfile/ast/cmd.go | 14 +++++++ testdata/timeout/Taskfile.yml | 29 ++++++++++++++ website/src/docs/reference/schema.md | 19 ++++++++++ website/src/public/schema.json | 16 ++++++++ 6 files changed, 147 insertions(+) create mode 100644 testdata/timeout/Taskfile.yml diff --git a/task.go b/task.go index 98d340c976..79fa78e130 100644 --- a/task.go +++ b/task.go @@ -376,12 +376,21 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in } } + if cmd.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cmd.Timeout) + defer cancel() + } + switch { case cmd.Task != "": reacquire := e.releaseConcurrencyLimit() defer reacquire() err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true}) + if err != nil && ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("task: [%s] command timeout exceeded (%s): %w", t.Name(), cmd.Timeout, err) + } var exitCode interp.ExitStatus if errors.As(err, &exitCode) && cmd.IgnoreError { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err) @@ -426,6 +435,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in if closeErr := closer(err); closeErr != nil { e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr) } + if err != nil && ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("task: [%s] command timeout exceeded (%s): %w", t.Name(), cmd.Timeout, err) + } var exitCode interp.ExitStatus if errors.As(err, &exitCode) && cmd.IgnoreError { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err) diff --git a/task_test.go b/task_test.go index b56930e77e..fd4bb31791 100644 --- a/task_test.go +++ b/task_test.go @@ -2523,6 +2523,63 @@ func TestErrorCode(t *testing.T) { } } +func TestCommandTimeout(t *testing.T) { + t.Parallel() + + const dir = "testdata/timeout" + tests := []struct { + name string + task string + expectError bool + errorContains string + }{ + { + name: "timeout exceeded", + task: "timeout-exceeded", + expectError: true, + errorContains: "timeout exceeded", + }, + { + name: "timeout not exceeded", + task: "timeout-not-exceeded", + expectError: false, + }, + { + name: "no timeout", + task: "no-timeout", + expectError: false, + }, + { + name: "multiple commands with timeout", + task: "multiple-cmds-timeout", + expectError: true, + errorContains: "timeout exceeded", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + require.NoError(t, e.Setup()) + + err := e.Run(t.Context(), &task.Call{Task: test.task}) + if test.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), test.errorContains) + } else { + require.NoError(t, err) + } + }) + } +} + func TestEvaluateSymlinksInPaths(t *testing.T) { // nolint:paralleltest // cannot run in parallel const dir = "testdata/evaluate_symlinks_in_paths" var buff bytes.Buffer diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 840234807f..8efd06a9e4 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -1,6 +1,8 @@ package ast import ( + "time" + "go.yaml.in/yaml/v3" "github.com/go-task/task/v3/errors" @@ -21,6 +23,7 @@ type Cmd struct { IgnoreError bool Defer bool Platforms []*Platform + Timeout time.Duration } func (c *Cmd) DeepCopy() *Cmd { @@ -40,6 +43,7 @@ func (c *Cmd) DeepCopy() *Cmd { IgnoreError: c.IgnoreError, Defer: c.Defer, Platforms: deepcopy.Slice(c.Platforms), + Timeout: c.Timeout, } } @@ -67,10 +71,20 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { IgnoreError bool `yaml:"ignore_error"` Defer *Defer Platforms []*Platform + Timeout string } if err := node.Decode(&cmdStruct); err != nil { return errors.NewTaskfileDecodeError(err, node) } + + if cmdStruct.Timeout != "" { + timeout, err := time.ParseDuration(cmdStruct.Timeout) + if err != nil { + return errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format") + } + c.Timeout = timeout + } + if cmdStruct.Defer != nil { // A deferred command diff --git a/testdata/timeout/Taskfile.yml b/testdata/timeout/Taskfile.yml new file mode 100644 index 0000000000..4675aeb433 --- /dev/null +++ b/testdata/timeout/Taskfile.yml @@ -0,0 +1,29 @@ +version: '3' + +tasks: + timeout-exceeded: + desc: Command that should timeout + cmds: + - cmd: sleep 10 + timeout: 1s + + timeout-not-exceeded: + desc: Command that completes within timeout + cmds: + - cmd: echo "quick command" + timeout: 5s + + no-timeout: + desc: Command with no timeout specified + cmds: + - echo "no timeout" + + multiple-cmds-timeout: + desc: Multiple commands where one exceeds its timeout + cmds: + - cmd: echo "first" + timeout: 1s + - cmd: sleep 10 + timeout: 1s + - cmd: echo "third" + timeout: 1s diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 4358ef0be3..27e174843c 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -816,6 +816,7 @@ tasks: platforms: [linux, darwin] set: [errexit] shopt: [globstar] + timeout: 5m ``` ### Task References @@ -932,6 +933,24 @@ tasks: if: '[ "{{.ITEM}}" != "b" ]' ``` +### Command Timeouts + +Use `timeout` to limit how long a command may run. The value uses Go duration +syntax (e.g. `30s`, `5m`, `1h30m`). + +```yaml +tasks: + deploy: + cmds: + - cmd: npm run build + timeout: 5m + - cmd: ./deploy.sh + timeout: 30m +``` + +When a command exceeds its timeout, it is terminated and the task fails with an +error, preventing commands from hanging indefinitely in a pipeline. + ## Shell Options ### Set Options diff --git a/website/src/public/schema.json b/website/src/public/schema.json index df0637b7ed..7f5c6fd6e7 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -352,6 +352,10 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, @@ -393,6 +397,10 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, @@ -445,6 +453,10 @@ "platforms": { "description": "Specifies which platforms the command should be run on.", "$ref": "#/definitions/platforms" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, @@ -475,6 +487,10 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, From c10c6987466b15b83878205d232e7e2eb7bbb8be Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 5 Aug 2026 12:04:44 +0200 Subject: [PATCH 2/3] fix: honor timeout for deferred tasks --- task.go | 4 ++++ task_test.go | 21 +++++++++++++++++++++ taskfile/ast/cmd.go | 17 +++++++++++++++++ taskfile/ast/defer.go | 19 +++++++++++-------- taskfile/ast/taskfile_test.go | 20 ++++++++++++++++++-- testdata/deferred/Taskfile.yml | 12 ++++++++++++ website/src/docs/reference/schema.md | 17 +++++++++++++++++ website/src/public/schema.json | 25 ++++++++++++++++++++++++- 8 files changed, 124 insertions(+), 11 deletions(-) diff --git a/task.go b/task.go index 79fa78e130..78f6d3abf1 100644 --- a/task.go +++ b/task.go @@ -342,6 +342,10 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d defer cancel() cmd := t.Cmds[i] + if cmd.Task != "" && cmd.Timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, cmd.Timeout) + defer cancel() + } cache := &templater.Cache{Vars: vars} extra := map[string]any{} diff --git a/task_test.go b/task_test.go index fd4bb31791..b9005b505b 100644 --- a/task_test.go +++ b/task_test.go @@ -2287,6 +2287,27 @@ task-1 ran successfully assert.Contains(t, buff.String(), "child task deferred value-from-parent") } +func TestDeferredTaskTimeout(t *testing.T) { + t.Parallel() + + const dir = "testdata/deferred" + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithVerbose(true), + ) + require.NoError(t, e.Setup()) + + start := time.Now() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "parent-with-timeout"})) + assert.Less(t, time.Since(start), 500*time.Millisecond) + assert.Contains(t, buff.String(), "parent completed") + assert.NotContains(t, buff.String(), "\ncleanup completed\n") + assert.Contains(t, buff.String(), "ignored error in deferred cmd") +} + func TestExitCodeZero(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 8efd06a9e4..a2d3796642 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -86,6 +86,11 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { } if cmdStruct.Defer != nil { + timeout, err := parseTimeout(cmdStruct.Defer.Timeout, node) + if err != nil { + return err + } + c.Timeout = timeout // A deferred command if cmdStruct.Defer.Cmd != "" { @@ -135,3 +140,15 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("command") } + +func parseTimeout(value string, node *yaml.Node) (time.Duration, error) { + if value == "" { + return 0, nil + } + + timeout, err := time.ParseDuration(value) + if err != nil { + return 0, errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format") + } + return timeout, nil +} diff --git a/taskfile/ast/defer.go b/taskfile/ast/defer.go index 300a20da18..4bac37bab2 100644 --- a/taskfile/ast/defer.go +++ b/taskfile/ast/defer.go @@ -7,10 +7,11 @@ import ( ) type Defer struct { - Cmd string - Task string - Vars *Vars - Silent bool + Cmd string + Task string + Vars *Vars + Silent bool + Timeout string } func (d *Defer) UnmarshalYAML(node *yaml.Node) error { @@ -26,10 +27,11 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var deferStruct struct { - Defer string - Task string - Vars *Vars - Silent bool + Defer string + Task string + Vars *Vars + Silent bool + Timeout string } if err := node.Decode(&deferStruct); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -38,6 +40,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error { d.Task = deferStruct.Task d.Vars = deferStruct.Vars d.Silent = deferStruct.Silent + d.Timeout = deferStruct.Timeout return nil } diff --git a/taskfile/ast/taskfile_test.go b/taskfile/ast/taskfile_test.go index 86e3f710e0..c6cf1006d7 100644 --- a/taskfile/ast/taskfile_test.go +++ b/taskfile/ast/taskfile_test.go @@ -2,6 +2,7 @@ package ast_test import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,8 +23,9 @@ vars: PARAM1: VALUE1 PARAM2: VALUE2 ` - yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }` - yamlDeferredCmd = `defer: echo 'test'` + yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }` + yamlDeferredCallWithTimeout = `defer: { task: some_task, timeout: 1s }` + yamlDeferredCmd = `defer: echo 'test'` ) tests := []struct { content string @@ -77,6 +79,11 @@ vars: Defer: true, }, }, + { + yamlDeferredCallWithTimeout, + &ast.Cmd{}, + &ast.Cmd{Task: "some_task", Defer: true, Timeout: time.Second}, + }, { yamlDep, &ast.Dep{}, @@ -110,3 +117,12 @@ vars: assert.Equal(t, test.expected, test.v) } } + +func TestDeferredTaskTimeoutParseError(t *testing.T) { + t.Parallel() + + var cmd ast.Cmd + err := yaml.Unmarshal([]byte(`defer: { task: some_task, timeout: invalid }`), &cmd) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid timeout format") +} diff --git a/testdata/deferred/Taskfile.yml b/testdata/deferred/Taskfile.yml index 9ea3d0aa52..de7215907c 100644 --- a/testdata/deferred/Taskfile.yml +++ b/testdata/deferred/Taskfile.yml @@ -27,3 +27,15 @@ tasks: child: cmds: - cmd: echo "child {{.VAR1}}" + + parent-with-timeout: + cmds: + - defer: + task: slow-cleanup + timeout: 100ms + silent: true + - echo 'parent completed' + + slow-cleanup: + cmds: + - sleep 1 && echo 'cleanup completed' diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 27e174843c..ad25786e9b 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -951,6 +951,23 @@ tasks: When a command exceeds its timeout, it is terminated and the task fails with an error, preventing commands from hanging indefinitely in a pipeline. +### Deferred Task Timeouts + +Use `timeout` to limit how long a deferred task call may run. The value uses Go +duration syntax (for example, `30s`, `5m`, or `1h30m`). + +```yaml +tasks: + deploy: + cmds: + - defer: + task: cleanup + timeout: 30s +``` + +A timed-out deferred task is logged and ignored, like other deferred-task +errors. + ## Shell Options ### Set Options diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 7f5c6fd6e7..3e2750069c 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -406,6 +406,29 @@ "additionalProperties": false, "required": ["cmd"] }, + "deferred_task_call": { + "type": "object", + "properties": { + "task": { + "description": "Name of the task to run", + "type": "string" + }, + "vars": { + "description": "Values passed to the task called", + "$ref": "#/definitions/vars" + }, + "silent": { + "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.", + "type": "boolean" + }, + "timeout": { + "description": "Maximum duration the deferred task is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" + } + }, + "additionalProperties": false, + "required": ["task"] + }, "defer_task_call": { "type": "object", "properties": { @@ -413,7 +436,7 @@ "description": "Run a command when the task completes. This command will run even when the task fails", "anyOf": [ { - "$ref": "#/definitions/task_call" + "$ref": "#/definitions/deferred_task_call" } ] } From 4771ff1e331fd587de3eed47c8d66a0b490e1732 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Wed, 5 Aug 2026 14:15:24 +0200 Subject: [PATCH 3/3] fix: simplify deferred timeout integration --- taskfile/ast/cmd.go | 22 ++++++---------------- website/src/public/schema.json | 25 +------------------------ 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index a2d3796642..96a69413f1 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -86,11 +86,13 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { } if cmdStruct.Defer != nil { - timeout, err := parseTimeout(cmdStruct.Defer.Timeout, node) - if err != nil { - return err + if cmdStruct.Defer.Timeout != "" { + timeout, err := time.ParseDuration(cmdStruct.Defer.Timeout) + if err != nil { + return errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format") + } + c.Timeout = timeout } - c.Timeout = timeout // A deferred command if cmdStruct.Defer.Cmd != "" { @@ -140,15 +142,3 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("command") } - -func parseTimeout(value string, node *yaml.Node) (time.Duration, error) { - if value == "" { - return 0, nil - } - - timeout, err := time.ParseDuration(value) - if err != nil { - return 0, errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format") - } - return timeout, nil -} diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 3e2750069c..7f5c6fd6e7 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -406,29 +406,6 @@ "additionalProperties": false, "required": ["cmd"] }, - "deferred_task_call": { - "type": "object", - "properties": { - "task": { - "description": "Name of the task to run", - "type": "string" - }, - "vars": { - "description": "Values passed to the task called", - "$ref": "#/definitions/vars" - }, - "silent": { - "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.", - "type": "boolean" - }, - "timeout": { - "description": "Maximum duration the deferred task is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", - "type": "string" - } - }, - "additionalProperties": false, - "required": ["task"] - }, "defer_task_call": { "type": "object", "properties": { @@ -436,7 +413,7 @@ "description": "Run a command when the task completes. This command will run even when the task fails", "anyOf": [ { - "$ref": "#/definitions/deferred_task_call" + "$ref": "#/definitions/task_call" } ] }