Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions taskfile/ast/cmd.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package ast

import (
"time"

"go.yaml.in/yaml/v3"

"github.com/go-task/task/v3/errors"
Expand All @@ -21,6 +23,7 @@ type Cmd struct {
IgnoreError bool
Defer bool
Platforms []*Platform
Timeout time.Duration
}

func (c *Cmd) DeepCopy() *Cmd {
Expand All @@ -40,6 +43,7 @@ func (c *Cmd) DeepCopy() *Cmd {
IgnoreError: c.IgnoreError,
Defer: c.Defer,
Platforms: deepcopy.Slice(c.Platforms),
Timeout: c.Timeout,
}
}

Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions testdata/timeout/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions website/src/docs/reference/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,7 @@ tasks:
platforms: [linux, darwin]
set: [errexit]
shopt: [globstar]
timeout: 5m
```

### Task References
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions website/src/public/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading