Compare commits

..

1 Commits

Author SHA1 Message Date
Valentin Maerten
24bbb61d8f 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
2026-06-30 22:25:48 +02:00
9 changed files with 150 additions and 62 deletions

View File

@@ -6,15 +6,14 @@
"schedule:weekly", "schedule:weekly",
":semanticCommitTypeAll(chore)" ":semanticCommitTypeAll(chore)"
], ],
"mode": "full",
"addLabels":["area: dependencies"], "addLabels":["area: dependencies"],
"osvVulnerabilityAlerts": true,
"postUpdateOptions": ["gomodTidy"],
"customManagers": [ "customManagers": [
{ {
"customType": "regex", "customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/"], "fileMatch": ["^\\.github/workflows/.*\\.ya?ml$"],
"matchStrings": [ "matchStrings": [
"uses:\\s*golangci/golangci-lint-action@\\S+(?:\\s*#[^\\n]*)?\\s+with:\\s+version:\\s*(?<currentValue>v[\\d.]+)" "uses:\\s*golangci/golangci-lint-action@\\S+\\s+with:\\s+version:\\s*(?<currentValue>v[\\d.]+)"
], ],
"datasourceTemplate": "github-releases", "datasourceTemplate": "github-releases",
"depNameTemplate": "golangci/golangci-lint" "depNameTemplate": "golangci/golangci-lint"

12
task.go
View File

@@ -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 { switch {
case cmd.Task != "": case cmd.Task != "":
reacquire := e.releaseConcurrencyLimit() reacquire := e.releaseConcurrencyLimit()
defer reacquire() defer reacquire()
err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true}) 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 var exitCode interp.ExitStatus
if errors.As(err, &exitCode) && cmd.IgnoreError { if errors.As(err, &exitCode) && cmd.IgnoreError {
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err) 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 { if closeErr := closer(err); closeErr != nil {
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr) 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 var exitCode interp.ExitStatus
if errors.As(err, &exitCode) && cmd.IgnoreError { if errors.As(err, &exitCode) && cmd.IgnoreError {
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err) e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)

View File

@@ -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 func TestEvaluateSymlinksInPaths(t *testing.T) { // nolint:paralleltest // cannot run in parallel
const dir = "testdata/evaluate_symlinks_in_paths" const dir = "testdata/evaluate_symlinks_in_paths"
var buff bytes.Buffer var buff bytes.Buffer

View File

@@ -1,6 +1,8 @@
package ast package ast
import ( import (
"time"
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
@@ -21,6 +23,7 @@ type Cmd struct {
IgnoreError bool IgnoreError bool
Defer bool Defer bool
Platforms []*Platform Platforms []*Platform
Timeout time.Duration
} }
func (c *Cmd) DeepCopy() *Cmd { func (c *Cmd) DeepCopy() *Cmd {
@@ -40,6 +43,7 @@ func (c *Cmd) DeepCopy() *Cmd {
IgnoreError: c.IgnoreError, IgnoreError: c.IgnoreError,
Defer: c.Defer, Defer: c.Defer,
Platforms: deepcopy.Slice(c.Platforms), 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"` IgnoreError bool `yaml:"ignore_error"`
Defer *Defer Defer *Defer
Platforms []*Platform Platforms []*Platform
Timeout string
} }
if err := node.Decode(&cmdStruct); err != nil { if err := node.Decode(&cmdStruct); err != nil {
return errors.NewTaskfileDecodeError(err, node) 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 { if cmdStruct.Defer != nil {
// A deferred command // A deferred command

View File

@@ -98,9 +98,6 @@ func (vars *Vars) Values() iter.Seq[Var] {
// ToCacheMap converts Vars to an unordered map containing only the static // ToCacheMap converts Vars to an unordered map containing only the static
// variables // variables
func (vars *Vars) ToCacheMap() (m map[string]any) { func (vars *Vars) ToCacheMap() (m map[string]any) {
if vars == nil || vars.om == nil {
return nil
}
defer vars.mutex.RUnlock() defer vars.mutex.RUnlock()
vars.mutex.RLock() vars.mutex.RLock()
m = make(map[string]any, vars.Len()) m = make(map[string]any, vars.Len())

View File

@@ -1,55 +0,0 @@
package ast
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestVars_ToCacheMap(t *testing.T) {
t.Parallel()
t.Run("nil receiver returns nil", func(t *testing.T) {
t.Parallel()
var vars *Vars
assert.Nil(t, vars.ToCacheMap())
})
t.Run("empty vars returns empty map", func(t *testing.T) {
t.Parallel()
vars := NewVars()
m := vars.ToCacheMap()
assert.NotNil(t, m)
assert.Empty(t, m)
})
t.Run("static values are included", func(t *testing.T) {
t.Parallel()
vars := NewVars(
&VarElement{Key: "FOO", Value: Var{Value: "bar"}},
&VarElement{Key: "NUM", Value: Var{Value: 42}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"FOO": "bar", "NUM": 42}, m)
})
t.Run("live values take precedence over static values", func(t *testing.T) {
t.Parallel()
vars := NewVars(
&VarElement{Key: "FOO", Value: Var{Value: "bar", Live: "live-bar"}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"FOO": "live-bar"}, m)
})
t.Run("dynamic variables are excluded", func(t *testing.T) {
t.Parallel()
sh := "echo hello"
vars := NewVars(
&VarElement{Key: "STATIC", Value: Var{Value: "ok"}},
&VarElement{Key: "DYNAMIC", Value: Var{Sh: &sh}},
)
m := vars.ToCacheMap()
assert.Equal(t, map[string]any{"STATIC": "ok"}, m)
})
}

29
testdata/timeout/Taskfile.yml vendored Normal file
View File

@@ -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

View File

@@ -798,6 +798,7 @@ tasks:
platforms: [linux, darwin] platforms: [linux, darwin]
set: [errexit] set: [errexit]
shopt: [globstar] shopt: [globstar]
timeout: 5m
``` ```
### Task References ### Task References
@@ -914,6 +915,24 @@ tasks:
if: '[ "{{.ITEM}}" != "b" ]' 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 ## Shell Options
### Set Options ### Set Options

View File

@@ -352,6 +352,10 @@
"if": { "if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string" "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, "additionalProperties": false,
@@ -393,6 +397,10 @@
"if": { "if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string" "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, "additionalProperties": false,
@@ -445,6 +453,10 @@
"platforms": { "platforms": {
"description": "Specifies which platforms the command should be run on.", "description": "Specifies which platforms the command should be run on.",
"$ref": "#/definitions/platforms" "$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, "additionalProperties": false,
@@ -475,6 +487,10 @@
"if": { "if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string" "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, "additionalProperties": false,