Skip to content

Commit fca1bfc

Browse files
znullCopilot
andcommitted
Avoid leaking pooled-stdout goroutine when cmd.Start() fails
Ensure that in case of cmd.Start() failure for pipelines that use pooled buffers, we don't leak a pooled-buffer-copy goroutine. Could be triggered/detected by using a command stage that fails on command-not-found under the race detector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 966c9e2 commit fca1bfc

2 files changed

Lines changed: 46 additions & 3 deletions

File tree

pipe/command.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ func (s *commandStage) Start(
162162
}
163163
}
164164

165+
closeEarlyClosers := func() {
166+
for _, closer := range earlyClosers {
167+
_ = closer.Close()
168+
}
169+
}
170+
171+
// On error, Close any pipes we created and wait for the goroutines to
172+
// exit before propagating the error.
173+
cleanupOnStartFailure := func() {
174+
closeEarlyClosers()
175+
_ = s.wg.Wait()
176+
}
177+
165178
// If the caller hasn't arranged otherwise, read the command's
166179
// standard error into our `stderr` field:
167180
if s.cmd.Stderr == nil {
@@ -171,6 +184,7 @@ func (s *commandStage) Start(
171184
// can be sure.
172185
p, err := s.cmd.StderrPipe()
173186
if err != nil {
187+
cleanupOnStartFailure()
174188
return err
175189
}
176190
s.wg.Go(func() error {
@@ -188,12 +202,11 @@ func (s *commandStage) Start(
188202
s.runInOwnProcessGroup()
189203

190204
if err := s.cmd.Start(); err != nil {
205+
cleanupOnStartFailure()
191206
return err
192207
}
193208

194-
for _, closer := range earlyClosers {
195-
_ = closer.Close()
196-
}
209+
closeEarlyClosers()
197210

198211
// Arrange for the process to be killed (gently) if the context
199212
// expires before the command exits normally:

pipe/command_starterror_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package pipe_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os/exec"
7+
"testing"
8+
9+
"github.com/github/go-pipe/pipe"
10+
)
11+
12+
// TestCommandStageStartFailureNoRace verifies that when `cmd.Start()`
13+
// fails (e.g. command not found), the goroutine that
14+
// `setupPooledStdout` spawned does not leak past `Pipeline.Run()`.
15+
// `bytes.Buffer.ReadFrom` writes to the buffer's slice header via
16+
// `grow()` before its first `Read()`, so a leaked goroutine races
17+
// with the caller's access to the destination buffer once Run
18+
// returns the error. Run a tight loop so `-race` is likely to catch
19+
// any regression.
20+
func TestCommandStageStartFailureNoRace(t *testing.T) {
21+
for i := 0; i < 50; i++ {
22+
var buf bytes.Buffer
23+
p := pipe.New(pipe.WithStdout(&buf))
24+
p.Add(pipe.CommandStage("nope", exec.Command("this-binary-does-not-exist-xyz123")))
25+
if err := p.Run(context.Background()); err == nil {
26+
t.Fatalf("expected error from non-existent command, got nil")
27+
}
28+
_ = buf.String()
29+
}
30+
}

0 commit comments

Comments
 (0)