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
10 changes: 10 additions & 0 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
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"

"gopkg.in/yaml.v3"

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

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

Expand All @@ -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
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 with different timeouts
cmds:
- cmd: echo "first"
timeout: 1s
- cmd: sleep 10
timeout: 1s
- cmd: echo "third"
timeout: 1s
30 changes: 30 additions & 0 deletions website/src/docs/reference/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@ tasks:
platforms: [linux, darwin]
set: [errexit]
shopt: [globstar]
timeout: 5m
```

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