@@ -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
354369func (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+
430482func (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