diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a230703c..4ea523b9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ - Further improved fingerprinting performance on large repositories: hashing source files now reuses a single buffer, reducing memory allocations by ~98% and wall-clock time by ~7% (#2925 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 diff --git a/errors/errors.go b/errors/errors.go index 1ed50e8741..b346cf5ac1 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -39,6 +39,7 @@ const ( CodeTaskCancelled CodeTaskMissingRequiredVars CodeTaskNotAllowedVars + CodeTaskTimedOut ) // TaskError extends the standard error interface with a Code method. This code will diff --git a/errors/errors_task.go b/errors/errors_task.go index 454b3a8fb7..ada6a8cecd 100644 --- a/errors/errors_task.go +++ b/errors/errors_task.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" "mvdan.cc/sh/v3/interp" ) @@ -51,6 +52,10 @@ func (err *TaskRunError) TaskExitCode() int { if errors.As(err.Err, &exit) { return int(exit) } + var timeout *TaskTimeoutError + if errors.As(err.Err, &timeout) { + return TimeoutExitCode + } return err.Code() } @@ -58,6 +63,25 @@ func (err *TaskRunError) Unwrap() error { 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. type TaskInternalError struct { TaskName string diff --git a/executor.go b/executor.go index 783f18ed0d..848bde2e5e 100644 --- a/executor.go +++ b/executor.go @@ -1,7 +1,6 @@ package task import ( - "context" "io" "os" "sync" @@ -79,7 +78,7 @@ type ( concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex - executionHashes map[string]context.Context + executionHashes map[string]*executionState executionHashesMutex sync.Mutex watchedDirs *xsync.Map[string, bool] } @@ -107,7 +106,7 @@ func NewExecutor(opts ...ExecutorOption) *Executor { concurrencySemaphore: nil, taskCallCount: map[string]*int32{}, mkdirMutexMap: map[string]*sync.Mutex{}, - executionHashes: map[string]context.Context{}, + executionHashes: map[string]*executionState{}, executionHashesMutex: sync.Mutex{}, } e.Options(opts...) diff --git a/setup.go b/setup.go index f24f8b8eb3..72c82e2d9b 100644 --- a/setup.go +++ b/setup.go @@ -261,7 +261,7 @@ func (e *Executor) setupDefaults() { } 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.mkdirMutexMap = make(map[string]*sync.Mutex, e.Taskfile.Tasks.Len()) diff --git a/task.go b/task.go index 98d340c976..52a249168a 100644 --- a/task.go +++ b/task.go @@ -277,13 +277,18 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2) } + if t.IgnoreError && isCommandFailure(err) { + e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err) + continue + } + var exitCode interp.ExitStatus - if errors.As(err, &exitCode) { - if t.IgnoreError { - e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err) - continue - } + var timeout *errors.TaskTimeoutError + switch { + case errors.As(err, &exitCode): deferredExitCode = uint8(exitCode) + case errors.As(err, &timeout): + deferredExitCode = errors.TimeoutExitCode } return err @@ -326,11 +331,20 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { for _, d := range t.Deps { g.Go(func() error { - err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) - if err != nil { - return err + depCtx := ctx + var timeout *errors.TaskTimeoutError + 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 }) } @@ -364,6 +378,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 { 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 if strings.TrimSpace(cmd.If) != "" { if err := execext.RunCommand(ctx, &execext.RunCommandOptions{ @@ -371,6 +394,9 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in Dir: t.Dir, Env: env.Get(t), }); err != nil { + if timedOut(ctx, timeout) { + return timeout + } e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name()) return nil } @@ -382,8 +408,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in defer reacquire() err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true}) - var exitCode interp.ExitStatus - if errors.As(err, &exitCode) && cmd.IgnoreError { + if err != nil && timedOut(ctx, timeout) { + err = timeout + } + if cmd.IgnoreError && isCommandFailure(err) { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err) return nil } @@ -426,8 +454,10 @@ 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) } - var exitCode interp.ExitStatus - if errors.As(err, &exitCode) && cmd.IgnoreError { + if err != nil && timedOut(ctx, timeout) { + err = timeout + } + if cmd.IgnoreError && isCommandFailure(err) { e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err) return nil } @@ -437,6 +467,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 { h, err := e.GetHash(t) if err != nil { @@ -449,7 +500,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func e.executionHashesMutex.Lock() - if otherExecutionCtx, ok := e.executionHashes[h]; ok { + if other, ok := e.executionHashes[h]; ok { e.executionHashesMutex.Unlock() e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h) @@ -457,17 +508,33 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func reacquire := e.releaseConcurrencyLimit() defer reacquire() - <-otherExecutionCtx.Done() - return nil - } + // A finished execution wins even if our context is done: there is + // 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) - defer cancel() + select { + 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() - 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 diff --git a/task_test.go b/task_test.go index b56930e77e..949bdcba26 100644 --- a/task_test.go +++ b/task_test.go @@ -2,6 +2,7 @@ package task_test import ( "bytes" + "context" "fmt" "io" "io/fs" @@ -959,6 +960,44 @@ func TestTaskIgnoreErrors(t *testing.T) { 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) { t.Parallel() @@ -2234,6 +2273,54 @@ func TestRunOnceSharedDeps(t *testing.T) { 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) { t.Parallel() @@ -2287,6 +2374,27 @@ task-1 ran successfully 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) { t.Parallel() @@ -2319,6 +2427,27 @@ func TestExitCodeOne(t *testing.T) { 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) { t.Parallel() @@ -2523,6 +2652,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 const dir = "testdata/evaluate_symlinks_in_paths" var buff bytes.Buffer diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 840234807f..73c55385e7 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -1,6 +1,8 @@ package ast import ( + "time" + "go.yaml.in/yaml/v3" "github.com/go-task/task/v3/errors" @@ -21,6 +23,7 @@ type Cmd struct { IgnoreError bool Defer bool Platforms []*Platform + Timeout time.Duration } func (c *Cmd) DeepCopy() *Cmd { @@ -40,6 +43,7 @@ func (c *Cmd) DeepCopy() *Cmd { IgnoreError: c.IgnoreError, Defer: c.Defer, 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"` Defer *Defer Platforms []*Platform + Timeout string } if err := node.Decode(&cmdStruct); err != nil { 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 { + // 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 if cmdStruct.Defer.Cmd != "" { @@ -121,3 +140,16 @@ func (c *Cmd) UnmarshalYAML(node *yaml.Node) error { 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 +} diff --git a/taskfile/ast/defer.go b/taskfile/ast/defer.go index 300a20da18..4bac37bab2 100644 --- a/taskfile/ast/defer.go +++ b/taskfile/ast/defer.go @@ -7,10 +7,11 @@ import ( ) type Defer struct { - Cmd string - Task string - Vars *Vars - Silent bool + Cmd string + Task string + Vars *Vars + Silent bool + Timeout string } func (d *Defer) UnmarshalYAML(node *yaml.Node) error { @@ -26,10 +27,11 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var deferStruct struct { - Defer string - Task string - Vars *Vars - Silent bool + Defer string + Task string + Vars *Vars + Silent bool + Timeout string } if err := node.Decode(&deferStruct); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -38,6 +40,7 @@ func (d *Defer) UnmarshalYAML(node *yaml.Node) error { d.Task = deferStruct.Task d.Vars = deferStruct.Vars d.Silent = deferStruct.Silent + d.Timeout = deferStruct.Timeout return nil } diff --git a/taskfile/ast/dep.go b/taskfile/ast/dep.go index d8781b3779..23c2b34250 100644 --- a/taskfile/ast/dep.go +++ b/taskfile/ast/dep.go @@ -1,6 +1,8 @@ package ast import ( + "time" + "go.yaml.in/yaml/v3" "github.com/go-task/task/v3/errors" @@ -8,10 +10,11 @@ import ( // Dep is a task dependency type Dep struct { - Task string - For *For - Vars *Vars - Silent bool + Task string + For *For + Vars *Vars + Silent bool + Timeout time.Duration } func (d *Dep) DeepCopy() *Dep { @@ -19,10 +22,11 @@ func (d *Dep) DeepCopy() *Dep { return nil } return &Dep{ - Task: d.Task, - For: d.For.DeepCopy(), - Vars: d.Vars.DeepCopy(), - Silent: d.Silent, + Task: d.Task, + For: d.For.DeepCopy(), + Vars: d.Vars.DeepCopy(), + Silent: d.Silent, + Timeout: d.Timeout, } } @@ -39,14 +43,22 @@ func (d *Dep) UnmarshalYAML(node *yaml.Node) error { case yaml.MappingNode: var taskCall struct { - Task string - For *For - Vars *Vars - Silent bool + Task string + For *For + Vars *Vars + Silent bool + Timeout string } if err := node.Decode(&taskCall); err != nil { 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.For = taskCall.For d.Vars = taskCall.Vars diff --git a/taskfile/ast/taskfile_test.go b/taskfile/ast/taskfile_test.go index 86e3f710e0..00e0754a2a 100644 --- a/taskfile/ast/taskfile_test.go +++ b/taskfile/ast/taskfile_test.go @@ -2,6 +2,7 @@ package ast_test import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,8 +23,10 @@ vars: PARAM1: VALUE1 PARAM2: VALUE2 ` - yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }` - yamlDeferredCmd = `defer: echo 'test'` + yamlDeferredCall = `defer: { task: some_task, vars: { PARAM1: "var" } }` + yamlDeferredCallWithTimeout = `{ defer: { task: some_task }, timeout: 1s }` + yamlDeferredCmd = `defer: echo 'test'` + yamlDeferredCmdWithTimeout = `{ defer: echo 'test', timeout: 1s }` ) tests := []struct { content string @@ -77,6 +80,16 @@ vars: 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, &ast.Dep{}, @@ -110,3 +123,55 @@ vars: 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) + }) + } +} diff --git a/testdata/deferred/Taskfile.yml b/testdata/deferred/Taskfile.yml index 9ea3d0aa52..90a0f672f8 100644 --- a/testdata/deferred/Taskfile.yml +++ b/testdata/deferred/Taskfile.yml @@ -27,3 +27,15 @@ tasks: child: cmds: - 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' diff --git a/testdata/dep_timeout/Taskfile.yml b/testdata/dep_timeout/Taskfile.yml new file mode 100644 index 0000000000..7a5d9adc51 --- /dev/null +++ b/testdata/dep_timeout/Taskfile.yml @@ -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" diff --git a/testdata/exit_code/Taskfile.yml b/testdata/exit_code/Taskfile.yml index 3170b5079e..9bac4038a4 100644 --- a/testdata/exit_code/Taskfile.yml +++ b/testdata/exit_code/Taskfile.yml @@ -23,3 +23,9 @@ tasks: cmds: - defer: echo FOO={{.FOO}} - DYNAMIC_FOO={{.DYNAMIC_FOO}} - {{.PREFIX}}{{.EXIT_CODE}} - exit 1 + + exit-timeout: + cmds: + - defer: echo {{.PREFIX}}{{.EXIT_CODE}} + - cmd: sleep 10 + timeout: 200ms diff --git a/testdata/ignore_errors/Taskfile.yml b/testdata/ignore_errors/Taskfile.yml index 31b82a70b9..0d9166818f 100644 --- a/testdata/ignore_errors/Taskfile.yml +++ b/testdata/ignore_errors/Taskfile.yml @@ -18,3 +18,23 @@ tasks: cmd-should-fail: cmds: - 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" diff --git a/testdata/run_once_failure/Taskfile.yml b/testdata/run_once_failure/Taskfile.yml new file mode 100644 index 0000000000..4379a2ebeb --- /dev/null +++ b/testdata/run_once_failure/Taskfile.yml @@ -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 diff --git a/testdata/run_once_timeout/Taskfile.yml b/testdata/run_once_timeout/Taskfile.yml new file mode 100644 index 0000000000..958539ea46 --- /dev/null +++ b/testdata/run_once_timeout/Taskfile.yml @@ -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 diff --git a/testdata/timeout/Taskfile.yml b/testdata/timeout/Taskfile.yml new file mode 100644 index 0000000000..d93428266f --- /dev/null +++ b/testdata/timeout/Taskfile.yml @@ -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 diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index 4358ef0be3..a601b6a3a1 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -816,6 +816,7 @@ tasks: platforms: [linux, darwin] set: [errexit] shopt: [globstar] + timeout: 5m ``` ### Task References @@ -932,6 +933,58 @@ tasks: if: '[ "{{.ITEM}}" != "b" ]' ``` +### Command Timeouts + +Use `timeout` to limit how long a command may run. The value uses Go duration +syntax (e.g. `30s`, `5m`, `1h30m`) 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 ### Set Options diff --git a/website/src/docs/reference/templating.md b/website/src/docs/reference/templating.md index ccf968c062..37bdbe941d 100644 --- a/website/src/docs/reference/templating.md +++ b/website/src/docs/reference/templating.md @@ -334,7 +334,8 @@ tasks: - **Type**: `int` - **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 tasks: diff --git a/website/src/public/schema.json b/website/src/public/schema.json index df0637b7ed..f8e780bf61 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -352,6 +352,10 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, @@ -393,11 +397,38 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, "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": { "type": "object", "properties": { @@ -405,9 +436,13 @@ "description": "Run a command when the task completes. This command will run even when the task fails", "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, @@ -423,6 +458,10 @@ "silent": { "description": "Hides task name and command from output. The command's output will still be redirected to `STDOUT` and `STDERR`.", "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, @@ -445,6 +484,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, @@ -475,6 +518,10 @@ "if": { "description": "A shell command to evaluate. If the exit code is non-zero, the command is skipped.", "type": "string" + }, + "timeout": { + "description": "Maximum duration the command is allowed to run before being terminated. Supports Go duration syntax (e.g., '5m', '30s', '1h').", + "type": "string" } }, "additionalProperties": false, @@ -497,6 +544,10 @@ "vars": { "description": "Values passed to the task called", "$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,