From 4d5f7337c13c6d397a48134a5d16c183e1ce7ffa Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 30 Jun 2026 10:29:20 +0200 Subject: [PATCH] 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 98d340c9..79fa78e1 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 db2417a0..e92f655e 100644 --- a/task_test.go +++ b/task_test.go @@ -2497,6 +2497,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 84023480..8efd06a9 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 00000000..4675aeb4 --- /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 71ead075..30effc49 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -798,6 +798,7 @@ tasks: platforms: [linux, darwin] set: [errexit] shopt: [globstar] + timeout: 5m ``` ### Task References @@ -914,6 +915,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 0a814bb4..5a74920a 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,