mirror of
https://github.com/go-task/task.git
synced 2026-08-29 10:08:27 +02:00
feat: command timeouts (#2898)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
2
setup.go
2
setup.go
@@ -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
104
task.go
@@ -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
|
||||||
|
|||||||
298
task_test.go
298
task_test.go
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
12
testdata/deferred/Taskfile.yml
vendored
12
testdata/deferred/Taskfile.yml
vendored
@@ -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
26
testdata/dep_timeout/Taskfile.yml
vendored
Normal 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"
|
||||||
6
testdata/exit_code/Taskfile.yml
vendored
6
testdata/exit_code/Taskfile.yml
vendored
@@ -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
|
||||||
|
|||||||
20
testdata/ignore_errors/Taskfile.yml
vendored
20
testdata/ignore_errors/Taskfile.yml
vendored
@@ -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
18
testdata/run_once_failure/Taskfile.yml
vendored
Normal 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
27
testdata/run_once_timeout/Taskfile.yml
vendored
Normal 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
57
testdata/timeout/Taskfile.yml
vendored
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user