Skip to content

Commit d97db50

Browse files
authored
fix(executor): stop counting skipped tasks twice on retry runs (#251)
A retry run creates a new executor over the full task set with pre-completed tasks. A pending task whose dependency is already failed takes the skip path in the first startReadyTasks call, which marks it failed and reports it on the completion channel. The finished counter was seeded after that call, so the skip was counted twice and the run could end while other tasks were still starting, panicking with "send on closed channel" when the next task emitted its preamble. Seed the counter before starting tasks, and stop closing the output channel: draining now ends on a dedicated signal, so a line from a goroutine that outlives the run is dropped rather than fatal.
1 parent 2e86782 commit d97db50

2 files changed

Lines changed: 150 additions & 27 deletions

File tree

executor/executor.go

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ func (e *Executor) Execute(ctx context.Context) error {
113113
semaphore = make(chan struct{}, e.opts.MaxParallelization)
114114
}
115115

116+
// Count pre-completed tasks before starting anything. startReadyTasks marks
117+
// dependency-skipped tasks as failed *and* reports them on completionCh, so
118+
// counting after it would count those tasks twice and end the run early.
119+
finished := 0
120+
for _, run := range e.tasks {
121+
if run.status == statusSuccess || run.status == statusFailed {
122+
finished++
123+
}
124+
}
125+
total := len(e.tasks)
126+
116127
if err := e.startReadyTasks(ctx, completionCh, outputCh, semaphore, executorDone); err != nil {
117128
return err
118129
}
@@ -121,19 +132,13 @@ func (e *Executor) Execute(ctx context.Context) error {
121132
return err
122133
}
123134

135+
drainStop := make(chan struct{})
124136
drainDone := make(chan struct{})
125137
go func() {
126-
e.drainOutput(ctx, outputCh)
138+
e.drainOutput(ctx, outputCh, drainStop)
127139
close(drainDone)
128140
}()
129141

130-
finished := 0
131-
for _, run := range e.tasks {
132-
if run.status == statusSuccess || run.status == statusFailed {
133-
finished++
134-
}
135-
}
136-
total := len(e.tasks)
137142
var errs []error
138143

139144
for finished < total {
@@ -198,7 +203,7 @@ func (e *Executor) Execute(ctx context.Context) error {
198203
}
199204
}
200205

201-
close(outputCh)
206+
close(drainStop)
202207
<-drainDone // wait for all output to be processed before returning
203208
return errors.Join(errs...)
204209
}
@@ -365,28 +370,34 @@ func (e *Executor) pendingNames() string {
365370
return strings.Join(names, ", ")
366371
}
367372

368-
func (e *Executor) drainOutput(ctx context.Context, outputCh <-chan Output) {
369-
for {
370-
select {
371-
case out, ok := <-outputCh:
372-
if !ok {
373+
// drainOutput forwards task output to the output handler until stop is closed or
374+
// ctx is cancelled. outputCh is deliberately never closed: a task goroutine that
375+
// outlives the run would panic sending on a closed channel, so it is left open
376+
// and such a send is simply dropped once draining has stopped.
377+
func (e *Executor) drainOutput(ctx context.Context, outputCh <-chan Output, stop <-chan struct{}) {
378+
// Drain any lines already queued before exiting so the last output of a
379+
// failing command is not lost.
380+
flush := func(handlerCtx context.Context) {
381+
for {
382+
select {
383+
case out := <-outputCh:
384+
_ = e.outputHandler.HandleOutput(handlerCtx, out)
385+
default:
373386
return
374387
}
388+
}
389+
}
390+
391+
for {
392+
select {
393+
case out := <-outputCh:
375394
_ = e.outputHandler.HandleOutput(ctx, out)
395+
case <-stop:
396+
flush(ctx)
397+
return
376398
case <-ctx.Done():
377-
// Drain any lines already queued before exiting so the last output of a
378-
// failing command is not lost when the context is cancelled.
379-
for {
380-
select {
381-
case out, ok := <-outputCh:
382-
if !ok {
383-
return
384-
}
385-
_ = e.outputHandler.HandleOutput(context.Background(), out)
386-
default:
387-
return
388-
}
389-
}
399+
flush(context.Background())
400+
return
390401
}
391402
}
392403
}

executor/executor_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,115 @@ func TestExecutor_DependencyBlocking(t *testing.T) {
437437
t.Error("dep started before base completed")
438438
}
439439
}
440+
441+
type collectingHandler struct {
442+
mu sync.Mutex
443+
lines []string
444+
}
445+
446+
func (c *collectingHandler) HandleOutput(_ context.Context, out Output) error {
447+
c.mu.Lock()
448+
c.lines = append(c.lines, string(out.Output))
449+
c.mu.Unlock()
450+
return nil
451+
}
452+
453+
func (c *collectingHandler) snapshot() []string {
454+
c.mu.Lock()
455+
defer c.mu.Unlock()
456+
return append([]string(nil), c.lines...)
457+
}
458+
459+
// A dependent of a pre-completed failed task is skipped during the very first
460+
// startReadyTasks call, which both marks it failed and reports it on the completion
461+
// channel. Counting it twice ends the run while other tasks are still executing.
462+
func TestExecutor_SkippedDependentOfPreCompletedFailureDoesNotEndRunEarly(t *testing.T) {
463+
started := make(chan struct{})
464+
release := make(chan struct{})
465+
466+
tasks := []Task{
467+
{
468+
Name: "slow-task",
469+
ExecuteFn: func(ctx context.Context, name string, h OutputHandler) error {
470+
close(started)
471+
<-release
472+
return h.HandleOutput(ctx, Output{Output: []byte("last line"), CmdName: name})
473+
},
474+
},
475+
{
476+
Name: "failed-task",
477+
ExecuteFn: func(ctx context.Context, name string, h OutputHandler) error { return nil },
478+
},
479+
{
480+
Name: "dependent",
481+
Needs: []string{"failed-task"},
482+
ExecuteFn: func(ctx context.Context, name string, h OutputHandler) error { return nil },
483+
},
484+
}
485+
486+
exec, err := NewExecutor(tasks, ExecutorOptions{})
487+
if err != nil {
488+
t.Fatalf("unexpected error: %v", err)
489+
}
490+
exec.WithPreCompleted(nil, []string{"failed-task"})
491+
handler := &collectingHandler{}
492+
exec.WithOutputHandler(handler)
493+
494+
done := make(chan error, 1)
495+
go func() { done <- exec.Execute(context.Background()) }()
496+
497+
<-started
498+
select {
499+
case <-done:
500+
t.Fatal("Execute returned while slow-task was still running")
501+
case <-time.After(100 * time.Millisecond):
502+
}
503+
504+
close(release)
505+
select {
506+
case err := <-done:
507+
if !errors.Is(err, ErrTaskSkipped) {
508+
t.Errorf("expected skipped dependent error, got %v", err)
509+
}
510+
case <-time.After(5 * time.Second):
511+
t.Fatal("Execute did not return after slow-task finished")
512+
}
513+
514+
if got := handler.snapshot(); len(got) != 1 || got[0] != "last line" {
515+
t.Errorf("expected slow-task output to be drained, got %v", got)
516+
}
517+
}
518+
519+
// Output emitted after the run has finished must be dropped, not fatal.
520+
func TestExecutor_OutputAfterRunFinishesIsDropped(t *testing.T) {
521+
emitted := make(chan struct{})
522+
523+
tasks := []Task{
524+
{
525+
Name: "task",
526+
ExecuteFn: func(ctx context.Context, name string, h OutputHandler) error {
527+
go func() {
528+
time.Sleep(20 * time.Millisecond)
529+
_ = h.HandleOutput(ctx, Output{Output: []byte("stray"), CmdName: name})
530+
close(emitted)
531+
}()
532+
return nil
533+
},
534+
},
535+
}
536+
537+
exec, err := NewExecutor(tasks, ExecutorOptions{})
538+
if err != nil {
539+
t.Fatalf("unexpected error: %v", err)
540+
}
541+
542+
if err := exec.Execute(context.Background()); err != nil {
543+
t.Errorf("unexpected error: %v", err)
544+
}
545+
546+
select {
547+
case <-emitted:
548+
case <-time.After(5 * time.Second):
549+
t.Fatal("stray output goroutine never completed")
550+
}
551+
}

0 commit comments

Comments
 (0)