feat: command timeouts (#2898)

This commit is contained in:
Valentin Maerten
2026-08-11 22:09:25 +02:00
committed by GitHub
parent 993508c782
commit 37898d9102
21 changed files with 830 additions and 50 deletions

View File

@@ -44,6 +44,13 @@
- Added Nushell completions, available via `task --completion nu`. They complete - Added Nushell completions, available via `task --completion nu`. They complete
task names and aliases, every flag with its description, and the values of task names and aliases, every flag with its description, and the values of
`--completion`, `--output` and `--sort` (#2966 by @vmaerten). `--completion`, `--output` and `--sort` (#2966 by @vmaerten).
- :warning: Added a per-command `timeout` that terminates a command once it
exceeds the given duration (Go duration syntax). It covers shell commands,
task calls, deferred commands, `deps` and the `if` condition, obeys
`ignore_error`, and reports exit code `124`. Callers that join a `run: once`
or `when_changed` task already running now honor their own `timeout`, and
inherit that task's failure instead of being told it succeeded (#1569, #2898
by @vmaerten).
## v3.52.0 - 2026-07-02 ## v3.52.0 - 2026-07-02

View File

@@ -39,6 +39,7 @@ const (
CodeTaskCancelled CodeTaskCancelled
CodeTaskMissingRequiredVars CodeTaskMissingRequiredVars
CodeTaskNotAllowedVars CodeTaskNotAllowedVars
CodeTaskTimedOut
) )
// TaskError extends the standard error interface with a Code method. This code will // TaskError extends the standard error interface with a Code method. This code will

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"mvdan.cc/sh/v3/interp" "mvdan.cc/sh/v3/interp"
) )
@@ -51,6 +52,10 @@ func (err *TaskRunError) TaskExitCode() int {
if errors.As(err.Err, &exit) { if errors.As(err.Err, &exit) {
return int(exit) return int(exit)
} }
var timeout *TaskTimeoutError
if errors.As(err.Err, &timeout) {
return TimeoutExitCode
}
return err.Code() return err.Code()
} }
@@ -58,6 +63,25 @@ func (err *TaskRunError) Unwrap() error {
return err.Err return err.Err
} }
// TimeoutExitCode is what a killed command reports in place of the exit status
// it never got, following the convention of timeout(1).
const TimeoutExitCode = 124
// TaskTimeoutError is returned when a command exceeds the timeout it declared.
// It must not unwrap to context.DeadlineExceeded, which --watch swallows.
type TaskTimeoutError struct {
TaskName string
Timeout time.Duration
}
func (err *TaskTimeoutError) Error() string {
return fmt.Sprintf(`task: [%s] command timeout exceeded (%s)`, err.TaskName, err.Timeout)
}
func (err *TaskTimeoutError) Code() int {
return CodeTaskTimedOut
}
// TaskInternalError when the user attempts to invoke a task that is internal. // TaskInternalError when the user attempts to invoke a task that is internal.
type TaskInternalError struct { type TaskInternalError struct {
TaskName string TaskName string

View File

@@ -1,7 +1,6 @@
package task package task
import ( import (
"context"
"io" "io"
"os" "os"
"sync" "sync"
@@ -80,7 +79,7 @@ type (
concurrencySemaphore chan struct{} concurrencySemaphore chan struct{}
taskCallCount map[string]*int32 taskCallCount map[string]*int32
mkdirMutexMap map[string]*sync.Mutex mkdirMutexMap map[string]*sync.Mutex
executionHashes map[string]context.Context executionHashes map[string]*executionState
executionHashesMutex sync.Mutex executionHashesMutex sync.Mutex
watchedDirs *xsync.Map[string, bool] watchedDirs *xsync.Map[string, bool]
} }
@@ -108,7 +107,7 @@ func NewExecutor(opts ...ExecutorOption) *Executor {
concurrencySemaphore: nil, concurrencySemaphore: nil,
taskCallCount: map[string]*int32{}, taskCallCount: map[string]*int32{},
mkdirMutexMap: map[string]*sync.Mutex{}, mkdirMutexMap: map[string]*sync.Mutex{},
executionHashes: map[string]context.Context{}, executionHashes: map[string]*executionState{},
executionHashesMutex: sync.Mutex{}, executionHashesMutex: sync.Mutex{},
} }
e.Options(opts...) e.Options(opts...)

View File

@@ -261,7 +261,7 @@ func (e *Executor) setupDefaults() {
} }
func (e *Executor) setupConcurrencyState() { func (e *Executor) setupConcurrencyState() {
e.executionHashes = make(map[string]context.Context) e.executionHashes = make(map[string]*executionState)
e.taskCallCount = make(map[string]*int32, e.Taskfile.Tasks.Len()) e.taskCallCount = make(map[string]*int32, e.Taskfile.Tasks.Len())
e.mkdirMutexMap = make(map[string]*sync.Mutex, e.Taskfile.Tasks.Len()) e.mkdirMutexMap = make(map[string]*sync.Mutex, e.Taskfile.Tasks.Len())

104
task.go
View File

@@ -266,14 +266,20 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2) e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
} }
var exitCode interp.ExitStatus if t.IgnoreError && isCommandFailure(err) {
if errors.As(err, &exitCode) {
if t.IgnoreError {
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err) e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
continue continue
} }
e.Logger.VerboseErrf(logger.Red, "task: %q failed: %v\n", call.Task, err) e.Logger.VerboseErrf(logger.Red, "task: %q failed: %v\n", call.Task, err)
var exitCode interp.ExitStatus
var timeout *errors.TaskTimeoutError
switch {
case errors.As(err, &exitCode):
deferredExitCode = uint8(exitCode) deferredExitCode = uint8(exitCode)
case errors.As(err, &timeout):
deferredExitCode = errors.TimeoutExitCode
} }
return err return err
@@ -316,11 +322,20 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error {
for _, d := range t.Deps { for _, d := range t.Deps {
g.Go(func() error { g.Go(func() error {
err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) depCtx := ctx
if err != nil { var timeout *errors.TaskTimeoutError
return err if d.Timeout > 0 {
timeout = &errors.TaskTimeoutError{TaskName: d.Task, Timeout: d.Timeout}
var cancel context.CancelFunc
depCtx, cancel = context.WithTimeoutCause(ctx, d.Timeout, timeout)
defer cancel()
} }
return nil
err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
if err != nil && timedOut(depCtx, timeout) {
return timeout
}
return err
}) })
} }
@@ -354,6 +369,15 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error { func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error {
cmd := t.Cmds[i] cmd := t.Cmds[i]
// In place before the if condition, which would otherwise run unbounded.
var timeout *errors.TaskTimeoutError
if cmd.Timeout > 0 {
timeout = &errors.TaskTimeoutError{TaskName: t.Name(), Timeout: cmd.Timeout}
var cancel context.CancelFunc
ctx, cancel = context.WithTimeoutCause(ctx, cmd.Timeout, timeout)
defer cancel()
}
// Check if condition for any command type // Check if condition for any command type
if strings.TrimSpace(cmd.If) != "" { if strings.TrimSpace(cmd.If) != "" {
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{ if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
@@ -361,6 +385,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
Dir: t.Dir, Dir: t.Dir,
Env: env.Get(t), Env: env.Get(t),
}); err != nil { }); err != nil {
if timedOut(ctx, timeout) {
return timeout
}
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name()) e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name())
return nil return nil
} }
@@ -372,8 +399,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
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})
var exitCode interp.ExitStatus if err != nil && timedOut(ctx, timeout) {
if errors.As(err, &exitCode) && cmd.IgnoreError { err = timeout
}
if cmd.IgnoreError && isCommandFailure(err) {
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)
return nil return nil
} }
@@ -416,8 +445,10 @@ 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)
} }
var exitCode interp.ExitStatus if err != nil && timedOut(ctx, timeout) {
if errors.As(err, &exitCode) && cmd.IgnoreError { err = timeout
}
if cmd.IgnoreError && isCommandFailure(err) {
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)
return nil return nil
} }
@@ -427,6 +458,27 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
} }
} }
// isCommandFailure reports whether the command failed on its own terms - a
// non-zero exit status or its timeout - rather than Task failing to run it.
func isCommandFailure(err error) bool {
var exitCode interp.ExitStatus
var timeout *errors.TaskTimeoutError
return errors.As(err, &exitCode) || errors.As(err, &timeout)
}
// timedOut reports whether ctx was cancelled by the given timeout rather than by
// an inherited deadline, which a derived context reports as its own.
func timedOut(ctx context.Context, timeout *errors.TaskTimeoutError) bool {
return timeout != nil && errors.Is(context.Cause(ctx), timeout)
}
// executionState is the outcome of a task execution, shared with the callers
// that join it. err is written before done is closed; read it only once closed.
type executionState struct {
done chan struct{}
err error
}
func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error { func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error {
h, err := e.GetHash(t) h, err := e.GetHash(t)
if err != nil { if err != nil {
@@ -439,7 +491,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
e.executionHashesMutex.Lock() e.executionHashesMutex.Lock()
if otherExecutionCtx, ok := e.executionHashes[h]; ok { if other, ok := e.executionHashes[h]; ok {
e.executionHashesMutex.Unlock() e.executionHashesMutex.Unlock()
e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h) e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h)
@@ -447,17 +499,33 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
reacquire := e.releaseConcurrencyLimit() reacquire := e.releaseConcurrencyLimit()
defer reacquire() defer reacquire()
<-otherExecutionCtx.Done() // A finished execution wins even if our context is done: there is
return nil // nothing left to wait for, and select would otherwise pick at random.
select {
case <-other.done:
return other.err
default:
} }
ctx, cancel := context.WithCancel(ctx) select {
defer cancel() case <-other.done:
// Its outcome is ours. Returning nil would hide an execution that
// failed, or that another caller's timeout killed.
return other.err
case <-ctx.Done():
// We did not start it, so we can only stop waiting. Report the cause
// so that our own timeout surfaces as one.
return context.Cause(ctx)
}
}
e.executionHashes[h] = ctx state := &executionState{done: make(chan struct{})}
e.executionHashes[h] = state
e.executionHashesMutex.Unlock() e.executionHashesMutex.Unlock()
return execute(ctx) defer close(state.done)
state.err = execute(ctx)
return state.err
} }
// FindMatchingTasks returns a list of tasks that match the given call. A task // FindMatchingTasks returns a list of tasks that match the given call. A task

View File

@@ -2,6 +2,7 @@ package task_test
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -1034,6 +1035,44 @@ func TestTaskIgnoreErrors(t *testing.T) {
require.Error(t, e.Run(t.Context(), &task.Call{Task: "cmd-should-fail"})) require.Error(t, e.Run(t.Context(), &task.Call{Task: "cmd-should-fail"}))
} }
func TestIgnoreErrorsOnTimeout(t *testing.T) {
t.Parallel()
const dir = "testdata/ignore_errors"
tests := []struct {
name string
task string
expectError bool
}{
{name: "ignored at task level", task: "task-timeout-should-pass"},
{name: "ignored at command level", task: "cmd-timeout-should-pass"},
{name: "not ignored", task: "cmd-timeout-should-fail", expectError: true},
}
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.NotContains(t, buff.String(), "reached the end")
return
}
require.NoError(t, err)
assert.Contains(t, buff.String(), "reached the end")
})
}
}
func TestExpand(t *testing.T) { func TestExpand(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2342,6 +2381,54 @@ func TestRunOnceSharedDeps(t *testing.T) {
assert.Contains(t, buff.String(), `task: [service-b:build] echo "build b"`) assert.Contains(t, buff.String(), `task: [service-b:build] echo "build b"`)
} }
func TestRunOnceSharedFailurePropagates(t *testing.T) {
t.Parallel()
const dir = "testdata/run_once_failure"
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: "default"})
require.Error(t, err)
assert.Contains(t, err.Error(), `Failed to run task "shared"`)
assert.NotContains(t, buff.String(), "should not be reached")
// The shared task still ran only once, which is the point of run: once.
assert.Equal(t, 1, strings.Count(buff.String(), "shared ran"))
}
func TestRunOnceJoinerHonorsItsOwnTimeout(t *testing.T) {
t.Parallel()
const dir = "testdata/run_once_timeout"
// The two deps run concurrently, so they need a buffer they can share.
var buff SyncBuffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())
start := time.Now()
err := e.Run(t.Context(), &task.Call{Task: "default"})
require.Error(t, err)
// The joiner used to wait on the shared execution alone, ignoring its own
// timeout for as long as that execution took.
assert.Less(t, time.Since(start), 5*time.Second)
var timeoutErr *errors.TaskTimeoutError
require.ErrorAs(t, err, &timeoutErr)
assert.Equal(t, "joiner", timeoutErr.TaskName)
assert.NotContains(t, buff.buf.String(), "should not be reached")
}
func TestRunWhenChanged(t *testing.T) { func TestRunWhenChanged(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2395,6 +2482,27 @@ task-1 ran successfully
assert.Contains(t, buff.String(), "child task deferred value-from-parent") assert.Contains(t, buff.String(), "child task deferred value-from-parent")
} }
func TestDeferredTaskTimeout(t *testing.T) {
t.Parallel()
const dir = "testdata/deferred"
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
task.WithVerbose(true),
)
require.NoError(t, e.Setup())
start := time.Now()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "parent-with-timeout"}))
assert.Less(t, time.Since(start), 500*time.Millisecond)
assert.Contains(t, buff.String(), "parent completed")
assert.NotContains(t, buff.String(), "\ncleanup completed\n")
assert.Contains(t, buff.String(), "ignored error in deferred cmd")
}
func TestExitCodeZero(t *testing.T) { func TestExitCodeZero(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2427,6 +2535,27 @@ func TestExitCodeOne(t *testing.T) {
assert.Equal(t, "FOO=bar - DYNAMIC_FOO=bar - EXIT_CODE=1", strings.TrimSpace(buff.String())) assert.Equal(t, "FOO=bar - DYNAMIC_FOO=bar - EXIT_CODE=1", strings.TrimSpace(buff.String()))
} }
func TestExitCodeTimeout(t *testing.T) {
t.Parallel()
const dir = "testdata/exit_code"
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: "exit-timeout"})
require.Error(t, err)
assert.Equal(t, "EXIT_CODE=124", strings.TrimSpace(buff.String()))
var runErr *errors.TaskRunError
require.ErrorAs(t, err, &runErr)
assert.Equal(t, errors.TimeoutExitCode, runErr.TaskExitCode())
}
func TestIgnoreNilElements(t *testing.T) { func TestIgnoreNilElements(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2631,6 +2760,175 @@ 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 TestDepTimeout(t *testing.T) {
t.Parallel()
const dir = "testdata/dep_timeout"
t.Run("timeout exceeded", func(t *testing.T) {
t.Parallel()
var buff SyncBuffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())
start := time.Now()
err := e.Run(t.Context(), &task.Call{Task: "timeout-exceeded"})
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second)
var timeoutErr *errors.TaskTimeoutError
require.ErrorAs(t, err, &timeoutErr)
assert.Equal(t, "slow", timeoutErr.TaskName)
assert.NotContains(t, buff.buf.String(), "should not be reached")
})
t.Run("timeout not exceeded", func(t *testing.T) {
t.Parallel()
var buff SyncBuffer
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "timeout-not-exceeded"}))
assert.Contains(t, buff.buf.String(), "reached the end")
})
}
func TestCommandTimeoutBoundsIfCondition(t *testing.T) {
t.Parallel()
var buff bytes.Buffer
e := task.NewExecutor(
task.WithDir("testdata/timeout"),
task.WithStdout(&buff),
task.WithStderr(&buff),
)
require.NoError(t, e.Setup())
start := time.Now()
err := e.Run(t.Context(), &task.Call{Task: "slow-if-condition"})
require.Error(t, err)
assert.Less(t, time.Since(start), 5*time.Second)
var timeoutErr *errors.TaskTimeoutError
require.ErrorAs(t, err, &timeoutErr)
// A condition that times out fails the command, it does not skip it.
assert.NotContains(t, buff.String(), "condition was met")
}
func TestCommandTimeoutAttribution(t *testing.T) {
t.Parallel()
const dir = "testdata/timeout"
tests := []struct {
name string
task string
notContains string
}{
{
name: "a command declaring no timeout is not blamed for one",
task: "inherited-timeout",
notContains: "(0s)",
},
{
name: "a command is not blamed for a timeout it never reached",
task: "larger-child-timeout",
notContains: "10m",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(io.Discard),
task.WithStderr(io.Discard),
)
require.NoError(t, e.Setup())
err := e.Run(t.Context(), &task.Call{Task: test.task})
require.Error(t, err)
assert.Contains(t, err.Error(), "command timeout exceeded (500ms)")
assert.NotContains(t, err.Error(), test.notContains)
var timeoutErr *errors.TaskTimeoutError
require.ErrorAs(t, err, &timeoutErr)
assert.Equal(t, test.task, timeoutErr.TaskName)
// --watch swallows context errors; a timeout must not look like one.
assert.False(t, errors.Is(err, context.DeadlineExceeded))
})
}
}
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,11 +71,26 @@ 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 := parseTimeout(cmdStruct.Timeout, node)
if err != nil {
return err
}
c.Timeout = timeout
}
if cmdStruct.Defer != nil { if cmdStruct.Defer != nil {
// Rejected rather than dropped: without the field, yaml would
// swallow the key without a word.
if cmdStruct.Defer.Timeout != "" {
return errors.NewTaskfileDecodeError(nil, node).WithMessage("timeout must be set next to defer, not inside it")
}
// A deferred command // A deferred command
if cmdStruct.Defer.Cmd != "" { if cmdStruct.Defer.Cmd != "" {
@@ -121,3 +140,16 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error {
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("command") return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("command")
} }
// parseTimeout rejects non-positive durations, which would otherwise read as no
// timeout at all - the unbounded run the key exists to prevent.
func parseTimeout(s string, node *yaml.Node) (time.Duration, error) {
timeout, err := time.ParseDuration(s)
if err != nil {
return 0, errors.NewTaskfileDecodeError(err, node).WithMessage("invalid timeout format")
}
if timeout <= 0 {
return 0, errors.NewTaskfileDecodeError(nil, node).WithMessage("timeout must be greater than zero")
}
return timeout, nil
}

View File

@@ -11,6 +11,7 @@ type Defer struct {
Task string Task string
Vars *Vars Vars *Vars
Silent bool Silent bool
Timeout string
} }
func (d *Defer) UnmarshalYAML(node *yaml.Node) error { func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
@@ -30,6 +31,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
Task string Task string
Vars *Vars Vars *Vars
Silent bool Silent bool
Timeout string
} }
if err := node.Decode(&deferStruct); err != nil { if err := node.Decode(&deferStruct); err != nil {
return errors.NewTaskfileDecodeError(err, node) return errors.NewTaskfileDecodeError(err, node)
@@ -38,6 +40,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error {
d.Task = deferStruct.Task d.Task = deferStruct.Task
d.Vars = deferStruct.Vars d.Vars = deferStruct.Vars
d.Silent = deferStruct.Silent d.Silent = deferStruct.Silent
d.Timeout = deferStruct.Timeout
return nil return nil
} }

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"
@@ -12,6 +14,7 @@ type Dep struct {
For *For For *For
Vars *Vars Vars *Vars
Silent bool Silent bool
Timeout time.Duration
} }
func (d *Dep) DeepCopy() *Dep { func (d *Dep) DeepCopy() *Dep {
@@ -23,6 +26,7 @@ func (d *Dep) DeepCopy() *Dep {
For: d.For.DeepCopy(), For: d.For.DeepCopy(),
Vars: d.Vars.DeepCopy(), Vars: d.Vars.DeepCopy(),
Silent: d.Silent, Silent: d.Silent,
Timeout: d.Timeout,
} }
} }
@@ -43,10 +47,18 @@ func (d *Dep) UnmarshalYAML(node *yaml.Node) error {
For *For For *For
Vars *Vars Vars *Vars
Silent bool Silent bool
Timeout string
} }
if err := node.Decode(&taskCall); err != nil { if err := node.Decode(&taskCall); err != nil {
return errors.NewTaskfileDecodeError(err, node) return errors.NewTaskfileDecodeError(err, node)
} }
if taskCall.Timeout != "" {
timeout, err := parseTimeout(taskCall.Timeout, node)
if err != nil {
return err
}
d.Timeout = timeout
}
d.Task = taskCall.Task d.Task = taskCall.Task
d.For = taskCall.For d.For = taskCall.For
d.Vars = taskCall.Vars d.Vars = taskCall.Vars

View File

@@ -2,6 +2,7 @@ package ast_test
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -23,7 +24,9 @@ vars:
PARAM2: VALUE2 PARAM2: VALUE2
` `
yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }` yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }`
yamlDeferredCallWithTimeout = `{ defer: { task: some_task }, timeout: 1s }`
yamlDeferredCmd = `defer: echo 'test'` yamlDeferredCmd = `defer: echo 'test'`
yamlDeferredCmdWithTimeout = `{ defer: echo 'test', timeout: 1s }`
) )
tests := []struct { tests := []struct {
content string content string
@@ -77,6 +80,16 @@ vars:
Defer: true, Defer: true,
}, },
}, },
{
yamlDeferredCallWithTimeout,
&ast.Cmd{},
&ast.Cmd{Task: "some_task", Defer: true, Timeout: time.Second},
},
{
yamlDeferredCmdWithTimeout,
&ast.Cmd{},
&ast.Cmd{Cmd: `echo 'test'`, Defer: true, Timeout: time.Second},
},
{ {
yamlDep, yamlDep,
&ast.Dep{}, &ast.Dep{},
@@ -110,3 +123,55 @@ vars:
assert.Equal(t, test.expected, test.v) assert.Equal(t, test.expected, test.v)
} }
} }
func TestTimeoutParseError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
message string
}{
{
name: "unparsable duration",
content: `{cmd: echo, timeout: invalid}`,
message: "invalid timeout format",
},
{
name: "zero duration",
content: `{cmd: echo, timeout: 0s}`,
message: "timeout must be greater than zero",
},
{
name: "negative duration",
content: `{cmd: echo, timeout: -1s}`,
message: "timeout must be greater than zero",
},
{
name: "negative duration on a deferred task",
content: `{defer: {task: some_task}, timeout: -5m}`,
message: "timeout must be greater than zero",
},
{
name: "unparsable duration on a deferred task",
content: `{defer: {task: some_task}, timeout: invalid}`,
message: "invalid timeout format",
},
{
name: "timeout nested inside defer",
content: `{defer: {task: some_task, timeout: 1s}}`,
message: "timeout must be set next to defer, not inside it",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
var cmd ast.Cmd
err := yaml.Unmarshal([]byte(test.content), &cmd)
require.Error(t, err)
assert.ErrorContains(t, err, test.message)
})
}
}

View File

@@ -27,3 +27,15 @@ tasks:
child: child:
cmds: cmds:
- cmd: echo "child {{.VAR1}}" - cmd: echo "child {{.VAR1}}"
parent-with-timeout:
cmds:
- defer:
task: slow-cleanup
silent: true
timeout: 100ms
- echo 'parent completed'
slow-cleanup:
cmds:
- sleep 1 && echo 'cleanup completed'

26
testdata/dep_timeout/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
version: '3'
silent: true
tasks:
timeout-exceeded:
deps:
- task: slow
timeout: 300ms
cmds:
- echo "should not be reached"
timeout-not-exceeded:
deps:
- task: quick
timeout: 5s
cmds:
- echo "reached the end"
slow:
cmds:
- sleep 10
quick:
cmds:
- echo "quick"

View File

@@ -23,3 +23,9 @@ tasks:
cmds: cmds:
- defer: echo FOO={{.FOO}} - DYNAMIC_FOO={{.DYNAMIC_FOO}} - {{.PREFIX}}{{.EXIT_CODE}} - defer: echo FOO={{.FOO}} - DYNAMIC_FOO={{.DYNAMIC_FOO}} - {{.PREFIX}}{{.EXIT_CODE}}
- exit 1 - exit 1
exit-timeout:
cmds:
- defer: echo {{.PREFIX}}{{.EXIT_CODE}}
- cmd: sleep 10
timeout: 200ms

View File

@@ -18,3 +18,23 @@ tasks:
cmd-should-fail: cmd-should-fail:
cmds: cmds:
- cmd: exit 1 - cmd: exit 1
task-timeout-should-pass:
cmds:
- cmd: sleep 10
timeout: 200ms
- echo "reached the end"
ignore_error: true
cmd-timeout-should-pass:
cmds:
- cmd: sleep 10
timeout: 200ms
ignore_error: true
- echo "reached the end"
cmd-timeout-should-fail:
cmds:
- cmd: sleep 10
timeout: 200ms
- echo "reached the end"

18
testdata/run_once_failure/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
version: '3'
silent: true
tasks:
default:
cmds:
- task: shared
ignore_error: true
# Joins the finished execution, and must inherit its error.
- task: shared
- echo "should not be reached"
shared:
run: once
cmds:
- echo "shared ran"
- exit 1

27
testdata/run_once_timeout/Taskfile.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
version: '3'
silent: true
tasks:
default:
# Without failfast the run waits for `owner` anyway, hiding what is tested.
failfast: true
deps: [owner, joiner]
# Holds the shared task far longer than `joiner` waits, so `joiner` reaches
# the deduplication path while it is still running.
owner:
cmds:
- task: shared
joiner:
cmds:
- sleep 0.2
- task: shared
timeout: 500ms
- echo "should not be reached"
shared:
run: once
cmds:
- sleep 10

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

@@ -0,0 +1,57 @@
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
slow-if-condition:
desc: Condition that hangs must be bounded by the command timeout
cmds:
- cmd: echo "condition was met"
if: sleep 10
timeout: 500ms
inherited-timeout:
desc: Calls a task whose command declares no timeout of its own
cmds:
- task: slow-without-timeout
timeout: 500ms
slow-without-timeout:
cmds:
- sleep 10
larger-child-timeout:
desc: Calls a task whose command declares a timeout it never reaches
cmds:
- task: slow-with-larger-timeout
timeout: 500ms
slow-with-larger-timeout:
cmds:
- cmd: sleep 10
timeout: 10m

View File

@@ -847,6 +847,7 @@ tasks:
platforms: [linux, darwin] platforms: [linux, darwin]
set: [errexit] set: [errexit]
shopt: [globstar] shopt: [globstar]
timeout: 5m
``` ```
### Task References ### Task References
@@ -963,6 +964,58 @@ 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`) and must be greater than zero.
```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. The timeout
bounds the whole step, so an [`if`](#command) condition that hangs is cut short
too, and [`ignore_error`](#command) covers a timeout like any other failure. A
timed-out command reports [`EXIT_CODE`](/docs/reference/templating#exit_code)
`124`, following the convention of `timeout(1)`.
A dependency takes the same key:
```yaml
tasks:
build:
deps:
- task: fetch-assets
timeout: 2m
```
The key goes next to the command whatever form it takes, including a `defer`:
```yaml
tasks:
deploy:
cmds:
- defer:
task: cleanup
timeout: 30s
- defer: ./cleanup.sh
timeout: 30s
```
A timed-out deferred command is logged and ignored, like other deferred errors.
Calling a task that is already running under [`run: once`](#task) or
[`run: when_changed`](#task) joins that execution instead of starting a second
one. A `timeout` on such a call bounds how long you wait for it, not the shared
execution itself, which only the caller that started it can bound.
## Shell Options ## Shell Options
### Set Options ### Set Options

View File

@@ -334,7 +334,8 @@ tasks:
- **Type**: `int` - **Type**: `int`
- **Description**: Failed command exit code (only in `defer`, only when - **Description**: Failed command exit code (only in `defer`, only when
non-zero) non-zero). A command killed by its [`timeout`](/docs/reference/schema#command)
is reported as `124`, following the convention of `timeout(1)`.
```yaml ```yaml
tasks: tasks:

View File

@@ -356,6 +356,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,
@@ -397,11 +401,38 @@
"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,
"required": ["cmd"] "required": ["cmd"]
}, },
"deferred_task_call": {
"type": "object",
"properties": {
"task": {
"description": "Name of the task to run",
"type": "string"
},
"vars": {
"description": "Values passed to the task called",
"$ref": "#/definitions/vars"
},
"silent": {
"description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.",
"type": "boolean"
},
"if": {
"description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.",
"type": "string"
}
},
"additionalProperties": false,
"required": ["task"]
},
"defer_task_call": { "defer_task_call": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -409,9 +440,13 @@
"description": "Run a command when the task completes. This command will run even when the task fails", "description": "Run a command when the task completes. This command will run even when the task fails",
"anyOf": [ "anyOf": [
{ {
"$ref": "#/definitions/task_call" "$ref": "#/definitions/deferred_task_call"
} }
] ]
},
"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,
@@ -427,6 +462,10 @@
"silent": { "silent": {
"description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.", "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.",
"type": "boolean" "type": "boolean"
},
"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,
@@ -471,6 +510,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,
@@ -505,6 +548,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,
@@ -527,6 +574,10 @@
"vars": { "vars": {
"description": "Values passed to the task called", "description": "Values passed to the task called",
"$ref": "#/definitions/vars" "$ref": "#/definitions/vars"
},
"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,