From 03a2d91da95963efed0951ec52ca421cf06beb70 Mon Sep 17 00:00:00 2001 From: Lachlan Donald Date: Fri, 24 Oct 2025 10:59:32 +1100 Subject: [PATCH 1/2] feat: add command-level timeout support Add timeout field to Cmd struct to terminate commands that exceed specified duration. Addresses use case from #1569 where commands can hang indefinitely in pipelines. Usage: cmds: - cmd: ./script.sh timeout: 5m --- task.go | 10 ++++++ task_test.go | 57 +++++++++++++++++++++++++++++++++++ taskfile/ast/cmd.go | 14 +++++++++ testdata/timeout/Taskfile.yml | 29 ++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 testdata/timeout/Taskfile.yml diff --git a/task.go b/task.go index db8b8a9d7c..fe93ea2662 100644 --- a/task.go +++ b/task.go @@ -307,6 +307,13 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, deferredExitCode func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error { cmd := t.Cmds[i] + // Apply command timeout if specified + if cmd.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cmd.Timeout) + defer cancel() + } + switch { case cmd.Task != "": reacquire := e.releaseConcurrencyLimit() @@ -355,6 +362,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 03ee2ee1b2..ffec729add 100644 --- a/task_test.go +++ b/task_test.go @@ -2115,6 +2115,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 3dab193c55..4eef06fec9 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -1,6 +1,8 @@ package ast import ( + "time" + "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" @@ -19,6 +21,7 @@ type Cmd struct { IgnoreError bool Defer bool Platforms []*Platform + Timeout time.Duration } func (c *Cmd) DeepCopy() *Cmd { @@ -36,6 +39,7 @@ func (c *Cmd) DeepCopy() *Cmd { IgnoreError: c.IgnoreError, Defer: c.Defer, Platforms: deepcopy.Slice(c.Platforms), + Timeout: c.Timeout, } } @@ -62,10 +66,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) } + + // Parse timeout if specified + 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..6c2fadf80f --- /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 with different timeouts + cmds: + - cmd: echo "first" + timeout: 1s + - cmd: sleep 10 + timeout: 1s + - cmd: echo "third" + timeout: 1s From ca2e90de5d40ec29d4a00350e33973209ee8802a Mon Sep 17 00:00:00 2001 From: Lachlan Donald Date: Fri, 24 Oct 2025 11:21:05 +1100 Subject: [PATCH 2/2] docs: add timeout property to command schema and reference Document the new timeout field for commands in both JSON schema and reference documentation with examples. --- website/src/docs/reference/schema.md | 30 ++++++++++++++++++++++++++++ website/src/public/schema.json | 8 ++++++++ 2 files changed, 38 insertions(+) diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index a6d81c3515..b2f6baf2e3 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -693,6 +693,7 @@ tasks: platforms: [linux, darwin] set: [errexit] shopt: [globstar] + timeout: 5m ``` ### Task References @@ -788,6 +789,35 @@ tasks: SERVICE: '{{.ITEM}}' ``` +## Command Properties + +### `timeout` + +- **Type**: `string` +- **Description**: Maximum duration a command can run before being + terminated. Uses Go duration syntax. +- **Examples**: `"30s"`, `"5m"`, `"1h30m"` + +```yaml +tasks: + deploy: + cmds: + # Build step with 5 minute timeout + - cmd: npm run build + timeout: 5m + + # Deploy with 30 minute timeout + - cmd: ./deploy.sh + timeout: 30m + + # Quick health check with 10 second timeout + - cmd: curl -f https://api.example.com/health + timeout: 10s +``` + +Commands that exceed their timeout will be terminated and return an error, +preventing indefinite hangs in task pipelines. + ## Shell Options ### Set Options diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 8605d98b44..dbf8653e87 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -361,6 +361,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, @@ -421,6 +425,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" } }, "oneOf": [{ "required": ["cmd"] }, { "required": ["task"] }],