Skip to content

Commit a274f74

Browse files
authored
Merge pull request #58 from github/moar-streams
Make the stream types more capable and use them in more places
2 parents 433e104 + 42a4910 commit a274f74

10 files changed

Lines changed: 308 additions & 248 deletions

pipe/close_responsibility_test.go

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,22 @@ import (
1515
// readCloseSpy records whether Close was called.
1616
type readCloseSpy struct {
1717
io.Reader
18-
closed atomic.Bool
18+
closeCount atomic.Uint32
1919
}
2020

2121
func (r *readCloseSpy) Close() error {
22-
r.closed.Store(true)
22+
r.closeCount.Add(1)
2323
return nil
2424
}
2525

2626
// writeCloseSpy records whether Close was called.
2727
type writeCloseSpy struct {
2828
io.Writer
29-
closed atomic.Bool
29+
closeCount atomic.Uint32
3030
}
3131

3232
func (w *writeCloseSpy) Close() error {
33-
w.closed.Store(true)
33+
w.closeCount.Add(1)
3434
return nil
3535
}
3636

@@ -63,28 +63,52 @@ func TestGoStageHonorsStreamOwnership(t *testing.T) {
6363
))
6464
require.NoError(t, s.Wait())
6565

66-
assert.Equal(t, !tc.leaveIn, in.closed.Load(), "closing stdin=%v", !tc.leaveIn)
67-
assert.Equal(t, !tc.leaveOut, out.closed.Load(), "closing stdout=%v", !tc.leaveOut)
66+
if tc.leaveIn {
67+
assert.EqualValues(t, 0, in.closeCount.Load(), "closing stdin=%v", !tc.leaveIn)
68+
} else {
69+
assert.EqualValues(t, 1, in.closeCount.Load(), "closing stdin=%v", !tc.leaveIn)
70+
}
71+
if tc.leaveOut {
72+
assert.EqualValues(t, 0, out.closeCount.Load(), "closing stdout=%v", !tc.leaveOut)
73+
} else {
74+
assert.EqualValues(t, 1, out.closeCount.Load(), "closing stdout=%v", !tc.leaveOut)
75+
}
6876
})
6977
}
7078
}
7179

7280
func TestStreamConstructorsPreserveOwnershipAndDynamicType(t *testing.T) {
73-
borrowedInput := strings.NewReader("borrowed")
74-
assert.Same(t, borrowedInput, Input(borrowedInput).Reader())
75-
assert.Nil(t, Input(borrowedInput).Closer())
76-
77-
ownedInput := &readCloseSpy{Reader: strings.NewReader("owned")}
78-
assert.Same(t, ownedInput, ClosingInput(ownedInput).Reader())
79-
assert.Same(t, ownedInput, ClosingInput(ownedInput).Closer())
80-
81-
borrowedOutput := &strings.Builder{}
82-
assert.Same(t, borrowedOutput, Output(borrowedOutput).Writer())
83-
assert.Nil(t, Output(borrowedOutput).Closer())
84-
85-
ownedOutput := &writeCloseSpy{Writer: io.Discard}
86-
assert.Same(t, ownedOutput, ClosingOutput(ownedOutput).Writer())
87-
assert.Same(t, ownedOutput, ClosingOutput(ownedOutput).Closer())
81+
borrowedReader := &readCloseSpy{Reader: strings.NewReader("borrowed")}
82+
borrowedInput := Input(borrowedReader)
83+
assert.Same(t, borrowedReader, borrowedInput.Reader())
84+
assert.NoError(t, borrowedInput.Close())
85+
assert.EqualValues(t, 0, borrowedReader.closeCount.Load())
86+
assert.NoError(t, borrowedInput.Close())
87+
assert.EqualValues(t, 0, borrowedReader.closeCount.Load())
88+
89+
ownedReader := &readCloseSpy{Reader: strings.NewReader("owned")}
90+
ownedInput := ClosingInput(ownedReader)
91+
assert.Same(t, ownedReader, ownedInput.Reader())
92+
assert.NoError(t, ownedInput.Close())
93+
assert.EqualValues(t, 1, ownedReader.closeCount.Load())
94+
assert.NoError(t, ownedInput.Close())
95+
assert.EqualValues(t, 1, ownedReader.closeCount.Load())
96+
97+
borrowedWriter := &writeCloseSpy{Writer: &strings.Builder{}}
98+
borrowedOutput := Output(borrowedWriter)
99+
assert.Same(t, borrowedWriter, borrowedOutput.Writer())
100+
assert.NoError(t, borrowedOutput.Close())
101+
assert.EqualValues(t, 0, borrowedWriter.closeCount.Load())
102+
assert.NoError(t, borrowedOutput.Close())
103+
assert.EqualValues(t, 0, borrowedWriter.closeCount.Load())
104+
105+
ownedWriter := &writeCloseSpy{Writer: &writeCloseSpy{Writer: io.Discard}}
106+
ownedOutput := ClosingOutput(ownedWriter)
107+
assert.Same(t, ownedWriter, ownedOutput.Writer())
108+
assert.NoError(t, ownedOutput.Close())
109+
assert.EqualValues(t, 1, ownedWriter.closeCount.Load())
110+
assert.NoError(t, ownedOutput.Close())
111+
assert.EqualValues(t, 1, ownedWriter.closeCount.Load())
88112
}
89113

90114
// TestCommandStageHonorsCloseStdin verifies that a command stage closes a
@@ -109,7 +133,11 @@ func TestCommandStageHonorsCloseStdin(t *testing.T) {
109133
))
110134
require.NoError(t, s.Wait())
111135

112-
assert.Equal(t, !leave, in.closed.Load(), "closing stdin=%v", !leave)
136+
if leave {
137+
assert.EqualValues(t, 0, in.closeCount.Load(), "closing stdin=%v", !leave)
138+
} else {
139+
assert.EqualValues(t, 1, in.closeCount.Load(), "closing stdin=%v", !leave)
140+
}
113141
})
114142
}
115143
}
@@ -136,19 +164,23 @@ func TestCommandStageHonorsCloseStdout(t *testing.T) {
136164
))
137165
require.NoError(t, s.Wait())
138166

139-
assert.Equal(t, !leave, out.closed.Load(), "closing stdout=%v", !leave)
167+
if leave {
168+
assert.EqualValues(t, 0, out.closeCount.Load(), "closing stdout=%v", !leave)
169+
} else {
170+
assert.EqualValues(t, 1, out.closeCount.Load(), "closing stdout=%v", !leave)
171+
}
140172
})
141173
}
142174
}
143175

144-
func inputForTest(r io.ReadCloser, closing bool) InputStream {
176+
func inputForTest(r io.ReadCloser, closing bool) *InputStream {
145177
if closing {
146178
return ClosingInput(r)
147179
}
148180
return Input(r)
149181
}
150182

151-
func outputForTest(w io.WriteCloser, closing bool) OutputStream {
183+
func outputForTest(w io.WriteCloser, closing bool) *OutputStream {
152184
if closing {
153185
return ClosingOutput(w)
154186
}

pipe/command.go

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,35 +88,60 @@ func (s *commandStage) Requirements() StageRequirements {
8888

8989
func (s *commandStage) Start(
9090
ctx context.Context, opts StageOptions,
91-
ins InputStream, outs OutputStream,
91+
stdin *InputStream, stdout *OutputStream,
9292
) error {
93-
stdin := ins.Reader()
94-
stdinCloser := ins.Closer()
95-
stdout := outs.Writer()
96-
stdoutCloser := outs.Closer()
93+
r := stdin.Reader()
94+
w := stdout.Writer()
9795

9896
if s.cmd.Dir == "" {
9997
s.cmd.Dir = opts.Dir
10098
}
10199

102100
s.setupEnv(ctx, opts.Env)
103101

102+
// It is important that the streams that are used by a command be
103+
// closed at the right time. When that is depends on the type of
104+
// the stream.
105+
//
106+
// A subprocess ultimately needs its own copies of `*os.File` file
107+
// descriptors for its stdin and stdout. The external command will
108+
// "always" close those when it exits.
109+
//
110+
// (It's theoretically possible for a command to pass the open
111+
// file descriptor to another, longer-lived process, in which case
112+
// the file descriptor wouldn't necessarily get closed even when
113+
// the command finishes. But that's ill-behaved in a command that
114+
// is being used in a pipeline, so we'll ignore that possibility.)
115+
//
116+
// If a stream provided for use as stdin/stdout is an `*os.File`,
117+
// then we set the corresponding field of `exec.Cmd` to that
118+
// argument. This causes `exec.Cmd` to duplicate that file
119+
// descriptor and passes the dup to the subprocess. Therefore, we
120+
// want to close our own copy "early", namely as soon as the
121+
// external command has started, because the external command will
122+
// keep its own copy open as long as necessary (and no longer!).
123+
//
124+
// If a stdin/stdout stream is _not_ an `*os.File`, then
125+
// `exec.Cmd` will take care of creating an `os.Pipe()`, copying
126+
// from the provided stream into/out of the pipe, and eventually
127+
// close both ends of the pipe. In that case, we must close the
128+
// provided stream "late", namely only after the external command
129+
// and the copy have finished.
130+
104131
// Things that have to be closed as soon as the command has started:
105132
var earlyClosers []io.Closer
106133

107134
// See the type comment for `Stage` for the explanation of this closing behavior.
108-
if stdin != nil {
109-
s.cmd.Stdin = stdin
135+
if r != nil {
136+
s.cmd.Stdin = r
110137
}
111138

112-
if stdinCloser != nil {
113-
if _, ok := stdin.(*os.File); ok {
114-
// We can close our copy as soon as the command has started
115-
earlyClosers = append(earlyClosers, stdinCloser)
116-
} else {
117-
// We need to close `stdin`, but only after the command has finished
118-
s.lateClosers = append(s.lateClosers, stdinCloser)
119-
}
139+
if _, ok := r.(*os.File); ok {
140+
// We can close our copy as soon as the command has started
141+
earlyClosers = append(earlyClosers, stdin)
142+
} else {
143+
// We need to close `stdin`, but only after the command has finished
144+
s.lateClosers = append(s.lateClosers, stdin)
120145
}
121146

122147
closeEarlyClosers := func() {
@@ -133,28 +158,24 @@ func (s *commandStage) Start(
133158
_ = s.closeLateClosers()
134159
}
135160

136-
if stdout != nil {
137-
if f, ok := stdout.(*os.File); ok {
161+
if w != nil {
162+
if f, ok := w.(*os.File); ok {
138163
s.cmd.Stdout = f
139-
if stdoutCloser != nil {
140-
earlyClosers = append(earlyClosers, stdoutCloser)
141-
}
164+
earlyClosers = append(earlyClosers, stdout)
142165
} else {
143-
if stdoutCloser != nil {
144-
s.lateClosers = append(s.lateClosers, stdoutCloser)
145-
}
166+
s.lateClosers = append(s.lateClosers, stdout)
146167
// Route the copy through our own pipe so we can use a
147168
// pooled buffer rather than letting exec.Cmd allocate a
148169
// fresh 32KB buffer for its internal io.Copy.
149-
ec, err := s.setupPooledStdout(stdout)
170+
ec, err := s.setupPooledStdout(w)
150171
if err != nil {
151172
cleanupOnStartFailure()
152173
return err
153174
}
154175
earlyClosers = append(earlyClosers, ec)
155176
}
156-
} else if stdoutCloser != nil {
157-
s.lateClosers = append(s.lateClosers, stdoutCloser)
177+
} else {
178+
s.lateClosers = append(s.lateClosers, stdout)
158179
}
159180

160181
// If the caller hasn't arranged otherwise, read the command's

pipe/command_stdout_fastpath_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ func TestCommandStageStdoutFastPath(t *testing.T) {
4242
cmd := exec.Command("true")
4343
s := CommandStage("true", cmd).(*commandStage)
4444

45-
stdout := OutputStream{writer: f}
45+
var stdout *OutputStream
4646
if tc.closingStdout {
4747
stdout = ClosingOutput(f)
48+
} else {
49+
stdout = Output(f)
4850
}
4951

5052
require.NoError(t, s.Start(ctx, StageOptions{}, Input(nil), stdout))

pipe/env_stage.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func (s *stageWithExtraEnv) Requirements() StageRequirements {
3737

3838
func (s *stageWithExtraEnv) Start(
3939
ctx context.Context, opts StageOptions,
40-
stdin InputStream, stdout OutputStream,
40+
stdin *InputStream, stdout *OutputStream,
4141
) error {
4242
opts.Vars = append(opts.Vars[:len(opts.Vars):len(opts.Vars)], func(_ context.Context, vars []EnvVar) []EnvVar {
4343
return append(vars, s.env...)

pipe/function.go

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,15 @@ func (s *goStage) Requirements() StageRequirements {
8787

8888
func (s *goStage) Start(
8989
ctx context.Context, opts StageOptions,
90-
stdin InputStream, stdout OutputStream,
90+
stdin *InputStream, stdout *OutputStream,
9191
) error {
9292
r := stdin.Reader()
93-
stdinCloser := stdin.Closer()
9493
if r == nil {
9594
// treat nil as empty input.
9695
r = strings.NewReader("")
9796
}
9897

9998
w := stdout.Writer()
100-
stdoutCloser := stdout.Closer()
10199
if w == nil {
102100
// treat nil output as /dev/null
103101
w = io.Discard
@@ -110,15 +108,11 @@ func (s *goStage) Start(
110108
s.err = opts.PanicHandler(p)
111109
}
112110
}
113-
if stdoutCloser != nil {
114-
if err := stdoutCloser.Close(); err != nil && s.err == nil {
115-
s.err = fmt.Errorf("error closing stdout for stage %q: %w", s.Name(), err)
116-
}
111+
if err := stdout.Close(); err != nil && s.err == nil {
112+
s.err = fmt.Errorf("error closing stdout for stage %q: %w", s.Name(), err)
117113
}
118-
if stdinCloser != nil {
119-
if err := stdinCloser.Close(); err != nil && s.err == nil {
120-
s.err = fmt.Errorf("error closing stdin for stage %q: %w", s.Name(), err)
121-
}
114+
if err := stdin.Close(); err != nil && s.err == nil {
115+
s.err = fmt.Errorf("error closing stdin for stage %q: %w", s.Name(), err)
122116
}
123117
close(s.done)
124118
}()

pipe/pipe_matching_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,12 @@ func (s *pipeSniffingStage) Requirements() pipe.StageRequirements {
104104

105105
func (s *pipeSniffingStage) Start(
106106
_ context.Context, _ pipe.StageOptions,
107-
stdin pipe.InputStream, stdout pipe.OutputStream,
107+
stdin *pipe.InputStream, stdout *pipe.OutputStream,
108108
) error {
109109
s.stdin = stdin.Reader()
110-
stdin.Close()
110+
_ = stdin.Close()
111111
s.stdout = stdout.Writer()
112-
stdout.Close()
112+
_ = stdout.Close()
113113
return nil
114114
}
115115

0 commit comments

Comments
 (0)