Skip to content

Commit 37898d9

Browse files
authored
feat: command timeouts (#2898)
1 parent 993508c commit 37898d9

21 files changed

Lines changed: 830 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@
4444
- Added Nushell completions, available via `task --completion nu`. They complete
4545
task names and aliases, every flag with its description, and the values of
4646
`--completion`, `--output` and `--sort` (#2966 by @vmaerten).
47+
- :warning: Added a per-command `timeout` that terminates a command once it
48+
exceeds the given duration (Go duration syntax). It covers shell commands,
49+
task calls, deferred commands, `deps` and the `if` condition, obeys
50+
`ignore_error`, and reports exit code `124`. Callers that join a `run: once`
51+
or `when_changed` task already running now honor their own `timeout`, and
52+
inherit that task's failure instead of being told it succeeded (#1569, #2898
53+
by @vmaerten).
4754

4855
## v3.52.0 - 2026-07-02
4956

errors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
CodeTaskCancelled
4040
CodeTaskMissingRequiredVars
4141
CodeTaskNotAllowedVars
42+
CodeTaskTimedOut
4243
)
4344

4445
// TaskError extends the standard error interface with a Code method. This code will

errors/errors_task.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"strings"
7+
"time"
78

89
"mvdan.cc/sh/v3/interp"
910
)
@@ -51,13 +52,36 @@ func (err *TaskRunError) TaskExitCode() int {
5152
if errors.As(err.Err, &exit) {
5253
return int(exit)
5354
}
55+
var timeout *TaskTimeoutError
56+
if errors.As(err.Err, &timeout) {
57+
return TimeoutExitCode
58+
}
5459
return err.Code()
5560
}
5661

5762
func (err *TaskRunError) Unwrap() error {
5863
return err.Err
5964
}
6065

66+
// TimeoutExitCode is what a killed command reports in place of the exit status
67+
// it never got, following the convention of timeout(1).
68+
const TimeoutExitCode = 124
69+
70+
// TaskTimeoutError is returned when a command exceeds the timeout it declared.
71+
// It must not unwrap to context.DeadlineExceeded, which --watch swallows.
72+
type TaskTimeoutError struct {
73+
TaskName string
74+
Timeout time.Duration
75+
}
76+
77+
func (err *TaskTimeoutError) Error() string {
78+
return fmt.Sprintf(`task: [%s] command timeout exceeded (%s)`, err.TaskName, err.Timeout)
79+
}
80+
81+
func (err *TaskTimeoutError) Code() int {
82+
return CodeTaskTimedOut
83+
}
84+
6185
// TaskInternalError when the user attempts to invoke a task that is internal.
6286
type TaskInternalError struct {
6387
TaskName string

executor.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package task
22

33
import (
4-
"context"
54
"io"
65
"os"
76
"sync"
@@ -80,7 +79,7 @@ type (
8079
concurrencySemaphore chan struct{}
8180
taskCallCount map[string]*int32
8281
mkdirMutexMap map[string]*sync.Mutex
83-
executionHashes map[string]context.Context
82+
executionHashes map[string]*executionState
8483
executionHashesMutex sync.Mutex
8584
watchedDirs *xsync.Map[string, bool]
8685
}
@@ -108,7 +107,7 @@ func NewExecutor(opts ...ExecutorOption) *Executor {
108107
concurrencySemaphore: nil,
109108
taskCallCount: map[string]*int32{},
110109
mkdirMutexMap: map[string]*sync.Mutex{},
111-
executionHashes: map[string]context.Context{},
110+
executionHashes: map[string]*executionState{},
112111
executionHashesMutex: sync.Mutex{},
113112
}
114113
e.Options(opts...)

setup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ func (e *Executor) setupDefaults() {
261261
}
262262

263263
func (e *Executor) setupConcurrencyState() {
264-
e.executionHashes = make(map[string]context.Context)
264+
e.executionHashes = make(map[string]*executionState)
265265

266266
e.taskCallCount = make(map[string]*int32, e.Taskfile.Tasks.Len())
267267
e.mkdirMutexMap = make(map[string]*sync.Mutex, e.Taskfile.Tasks.Len())

task.go

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -266,14 +266,20 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error {
266266
e.Logger.VerboseErrf(logger.Yellow, "task: error cleaning status on error: %v\n", err2)
267267
}
268268

269+
if t.IgnoreError && isCommandFailure(err) {
270+
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
271+
continue
272+
}
273+
274+
e.Logger.VerboseErrf(logger.Red, "task: %q failed: %v\n", call.Task, err)
275+
269276
var exitCode interp.ExitStatus
270-
if errors.As(err, &exitCode) {
271-
if t.IgnoreError {
272-
e.Logger.VerboseErrf(logger.Yellow, "task: task error ignored: %v\n", err)
273-
continue
274-
}
275-
e.Logger.VerboseErrf(logger.Red, "task: %q failed: %v\n", call.Task, err)
277+
var timeout *errors.TaskTimeoutError
278+
switch {
279+
case errors.As(err, &exitCode):
276280
deferredExitCode = uint8(exitCode)
281+
case errors.As(err, &timeout):
282+
deferredExitCode = errors.TimeoutExitCode
277283
}
278284

279285
return err
@@ -316,11 +322,20 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error {
316322

317323
for _, d := range t.Deps {
318324
g.Go(func() error {
319-
err := e.RunTask(ctx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
320-
if err != nil {
321-
return err
325+
depCtx := ctx
326+
var timeout *errors.TaskTimeoutError
327+
if d.Timeout > 0 {
328+
timeout = &errors.TaskTimeoutError{TaskName: d.Task, Timeout: d.Timeout}
329+
var cancel context.CancelFunc
330+
depCtx, cancel = context.WithTimeoutCause(ctx, d.Timeout, timeout)
331+
defer cancel()
322332
}
323-
return nil
333+
334+
err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true})
335+
if err != nil && timedOut(depCtx, timeout) {
336+
return timeout
337+
}
338+
return err
324339
})
325340
}
326341

@@ -354,13 +369,25 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
354369
func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i int) error {
355370
cmd := t.Cmds[i]
356371

372+
// In place before the if condition, which would otherwise run unbounded.
373+
var timeout *errors.TaskTimeoutError
374+
if cmd.Timeout > 0 {
375+
timeout = &errors.TaskTimeoutError{TaskName: t.Name(), Timeout: cmd.Timeout}
376+
var cancel context.CancelFunc
377+
ctx, cancel = context.WithTimeoutCause(ctx, cmd.Timeout, timeout)
378+
defer cancel()
379+
}
380+
357381
// Check if condition for any command type
358382
if strings.TrimSpace(cmd.If) != "" {
359383
if err := execext.RunCommand(ctx, &execext.RunCommandOptions{
360384
Command: cmd.If,
361385
Dir: t.Dir,
362386
Env: env.Get(t),
363387
}); err != nil {
388+
if timedOut(ctx, timeout) {
389+
return timeout
390+
}
364391
e.Logger.VerboseOutf(logger.Yellow, "task: [%s] if condition not met - skipped\n", t.Name())
365392
return nil
366393
}
@@ -372,8 +399,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
372399
defer reacquire()
373400

374401
err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true})
375-
var exitCode interp.ExitStatus
376-
if errors.As(err, &exitCode) && cmd.IgnoreError {
402+
if err != nil && timedOut(ctx, timeout) {
403+
err = timeout
404+
}
405+
if cmd.IgnoreError && isCommandFailure(err) {
377406
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] task error ignored: %v\n", t.Name(), err)
378407
return nil
379408
}
@@ -416,8 +445,10 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
416445
if closeErr := closer(err); closeErr != nil {
417446
e.Logger.Errf(logger.Red, "task: unable to close writer: %v\n", closeErr)
418447
}
419-
var exitCode interp.ExitStatus
420-
if errors.As(err, &exitCode) && cmd.IgnoreError {
448+
if err != nil && timedOut(ctx, timeout) {
449+
err = timeout
450+
}
451+
if cmd.IgnoreError && isCommandFailure(err) {
421452
e.Logger.VerboseErrf(logger.Yellow, "task: [%s] command error ignored: %v\n", t.Name(), err)
422453
return nil
423454
}
@@ -427,6 +458,27 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
427458
}
428459
}
429460

461+
// isCommandFailure reports whether the command failed on its own terms - a
462+
// non-zero exit status or its timeout - rather than Task failing to run it.
463+
func isCommandFailure(err error) bool {
464+
var exitCode interp.ExitStatus
465+
var timeout *errors.TaskTimeoutError
466+
return errors.As(err, &exitCode) || errors.As(err, &timeout)
467+
}
468+
469+
// timedOut reports whether ctx was cancelled by the given timeout rather than by
470+
// an inherited deadline, which a derived context reports as its own.
471+
func timedOut(ctx context.Context, timeout *errors.TaskTimeoutError) bool {
472+
return timeout != nil && errors.Is(context.Cause(ctx), timeout)
473+
}
474+
475+
// executionState is the outcome of a task execution, shared with the callers
476+
// that join it. err is written before done is closed; read it only once closed.
477+
type executionState struct {
478+
done chan struct{}
479+
err error
480+
}
481+
430482
func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error {
431483
h, err := e.GetHash(t)
432484
if err != nil {
@@ -439,25 +491,41 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func
439491

440492
e.executionHashesMutex.Lock()
441493

442-
if otherExecutionCtx, ok := e.executionHashes[h]; ok {
494+
if other, ok := e.executionHashes[h]; ok {
443495
e.executionHashesMutex.Unlock()
444496
e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h)
445497

446498
// Release our execution slot to avoid blocking other tasks while we wait
447499
reacquire := e.releaseConcurrencyLimit()
448500
defer reacquire()
449501

450-
<-otherExecutionCtx.Done()
451-
return nil
452-
}
502+
// A finished execution wins even if our context is done: there is
503+
// nothing left to wait for, and select would otherwise pick at random.
504+
select {
505+
case <-other.done:
506+
return other.err
507+
default:
508+
}
453509

454-
ctx, cancel := context.WithCancel(ctx)
455-
defer cancel()
510+
select {
511+
case <-other.done:
512+
// Its outcome is ours. Returning nil would hide an execution that
513+
// failed, or that another caller's timeout killed.
514+
return other.err
515+
case <-ctx.Done():
516+
// We did not start it, so we can only stop waiting. Report the cause
517+
// so that our own timeout surfaces as one.
518+
return context.Cause(ctx)
519+
}
520+
}
456521

457-
e.executionHashes[h] = ctx
522+
state := &executionState{done: make(chan struct{})}
523+
e.executionHashes[h] = state
458524
e.executionHashesMutex.Unlock()
459525

460-
return execute(ctx)
526+
defer close(state.done)
527+
state.err = execute(ctx)
528+
return state.err
461529
}
462530

463531
// FindMatchingTasks returns a list of tasks that match the given call. A task

0 commit comments

Comments
 (0)