WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit fddafcc

Browse files
committed
Reimplement process exit expect failure
Avoid cyclic dependencies; output consumers or producers should have no awareness of termtest
1 parent 45f7444 commit fddafcc

File tree

5 files changed

+61
-44
lines changed

5 files changed

+61
-44
lines changed

expect.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (tt *TermTest) ExpectCustom(consumer consumer, opts ...SetExpectOpt) (rerr
8686
return fmt.Errorf("could not create expect options: %w", err)
8787
}
8888

89-
cons, err := tt.outputProducer.addConsumer(tt, consumer, expectOpts.ToConsumerOpts()...)
89+
cons, err := tt.outputProducer.addConsumer(consumer, expectOpts.ToConsumerOpts()...)
9090
if err != nil {
9191
return fmt.Errorf("could not add consumer: %w", err)
9292
}
@@ -180,11 +180,11 @@ func (tt *TermTest) expectExitCode(exitCode int, match bool, opts ...SetExpectOp
180180
select {
181181
case <-time.After(timeoutV):
182182
return fmt.Errorf("after %s: %w", timeoutV, TimeoutError)
183-
case state := <-tt.Exited(false): // do not wait for unread output since it's not read by this select{}
184-
if state.Err != nil && (state.ProcessState == nil || state.ProcessState.ExitCode() == 0) {
185-
return fmt.Errorf("cmd wait failed: %w", state.Err)
183+
case err := <-waitChan(tt.cmd.Wait):
184+
if err != nil && (tt.cmd.ProcessState == nil || tt.cmd.ProcessState.ExitCode() == 0) {
185+
return fmt.Errorf("cmd wait failed: %w", err)
186186
}
187-
if err := tt.assertExitCode(state.ProcessState.ExitCode(), exitCode, match); err != nil {
187+
if err := tt.assertExitCode(tt.cmd.ProcessState.ExitCode(), exitCode, match); err != nil {
188188
return err
189189
}
190190
}

expect_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,27 @@ func Test_Expect_Timeout(t *testing.T) {
216216
tt.ExpectExitCode(0, OptExpectTimeout(time.Hour))
217217
}
218218

219+
// Test_ExpectMet_ProcessExit tests a potential race condition; where the process exiting event might hit before all output
220+
// has been fully consumed.
221+
func Test_ExpectMet_ProcessExit(t *testing.T) {
222+
tt := newTermTest(t, exec.Command("bash", "-c", "echo HELLO && exit 1"), false)
223+
tt.Expect("HELLO")
224+
tt.ExpectExitCode(1)
225+
}
226+
227+
// Test_ExpectFail_ProcessExit tests that we don't wait for the timeout if an expect is still waiting for output after
228+
// the process has exited.
229+
func Test_ExpectFail_ProcessExit(t *testing.T) {
230+
tt := newTermTest(t, exec.Command("bash", "-c", "echo HELLO && exit 1"), true)
231+
start := time.Now()
232+
err := tt.Expect("GOODBYE", OptExpectTimeout(5*time.Second), OptExpectSilenceErrorHandler())
233+
require.ErrorIs(t, err, ptyEOF)
234+
if time.Now().Sub(start) >= 5*time.Second {
235+
t.Errorf("Should not have waited for timeout as process has exited")
236+
}
237+
tt.ExpectExitCode(1)
238+
}
239+
219240
func Test_ExpectMatchTwiceSameBuffer(t *testing.T) {
220241
tt := newTermTest(t, exec.Command("bash"), false)
221242

outputconsumer.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ type outputConsumer struct {
1515
opts *OutputConsumerOpts
1616
isalive bool
1717
mutex *sync.Mutex
18-
tt *TermTest
1918
}
2019

2120
type OutputConsumerOpts struct {
@@ -37,7 +36,7 @@ func OptsConsTimeout(timeout time.Duration) func(o *OutputConsumerOpts) {
3736
}
3837
}
3938

40-
func newOutputConsumer(tt *TermTest, consume consumer, opts ...SetConsOpt) *outputConsumer {
39+
func newOutputConsumer(consume consumer, opts ...SetConsOpt) *outputConsumer {
4140
oc := &outputConsumer{
4241
consume: consume,
4342
opts: &OutputConsumerOpts{
@@ -47,7 +46,6 @@ func newOutputConsumer(tt *TermTest, consume consumer, opts ...SetConsOpt) *outp
4746
waiter: make(chan error, 1),
4847
isalive: true,
4948
mutex: &sync.Mutex{},
50-
tt: tt,
5149
}
5250

5351
for _, optSetter := range opts {
@@ -83,6 +81,23 @@ func (e *outputConsumer) Report(buffer []byte) (int, error) {
8381
return pos, err
8482
}
8583

84+
type errConsumerStopped struct {
85+
reason error
86+
}
87+
88+
func (e errConsumerStopped) Error() string {
89+
return fmt.Sprintf("consumer stopped, reason: %s", e.reason)
90+
}
91+
92+
func (e errConsumerStopped) Unwrap() error {
93+
return e.reason
94+
}
95+
96+
func (e *outputConsumer) Stop(reason error) {
97+
e.opts.Logger.Printf("stopping consumer, reason: %s\n", reason)
98+
e.waiter <- errConsumerStopped{reason}
99+
}
100+
86101
func (e *outputConsumer) wait() error {
87102
e.opts.Logger.Println("started waiting")
88103
defer e.opts.Logger.Println("stopped waiting")
@@ -103,11 +118,5 @@ func (e *outputConsumer) wait() error {
103118
e.mutex.Lock()
104119
e.opts.Logger.Println("Encountered timeout")
105120
return fmt.Errorf("after %s: %w", e.opts.Timeout, TimeoutError)
106-
case state := <-e.tt.Exited(true): // allow for output to be read first by first case in this select{}
107-
e.mutex.Lock()
108-
if state.Err != nil {
109-
e.opts.Logger.Println("Encountered error waiting for process to exit: %s\n", state.Err.Error())
110-
}
111-
return fmt.Errorf("process exited (status: %d)", state.ProcessState.ExitCode())
112121
}
113122
}

outputproducer.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ func (o *outputProducer) listen(r io.Reader, w io.Writer, appendBuffer func([]by
5555
o.opts.Logger.Println("listen: loop")
5656
if err := o.processNextRead(br, w, appendBuffer, size); err != nil {
5757
if errors.Is(err, ptyEOF) {
58-
o.opts.Logger.Println("listen: reached EOF")
5958
return nil
6059
} else {
6160
return fmt.Errorf("could not poll reader: %w", err)
@@ -78,6 +77,7 @@ func (o *outputProducer) processNextRead(r io.Reader, w io.Writer, appendBuffer
7877
pathError := &fs.PathError{}
7978
if errors.Is(errRead, fs.ErrClosed) || errors.Is(errRead, io.EOF) || (runtime.GOOS == "linux" && errors.As(errRead, &pathError)) {
8079
isEOF = true
80+
o.opts.Logger.Println("reached EOF")
8181
}
8282
}
8383

@@ -96,6 +96,7 @@ func (o *outputProducer) processNextRead(r io.Reader, w io.Writer, appendBuffer
9696

9797
if errRead != nil {
9898
if isEOF {
99+
o.closeConsumers(ptyEOF)
99100
return errors.Join(errRead, ptyEOF)
100101
}
101102
return fmt.Errorf("could not read pty output: %w", errRead)
@@ -194,6 +195,19 @@ func (o *outputProducer) processDirtyOutput(output []byte, cursorPos int, cleanU
194195
return append(append(alreadyCleanedOutput, processedOutput...), unprocessedOutput...), processedCursorPos, newCleanUptoPos, nil
195196
}
196197

198+
func (o *outputProducer) closeConsumers(reason error) {
199+
o.opts.Logger.Println("closing consumers")
200+
defer o.opts.Logger.Println("closed consumers")
201+
202+
o.mutex.Lock()
203+
defer o.mutex.Unlock()
204+
205+
for n := 0; n < len(o.consumers); n++ {
206+
o.consumers[n].Stop(reason)
207+
o.consumers = append(o.consumers[:n], o.consumers[n+1:]...)
208+
}
209+
}
210+
197211
func (o *outputProducer) flushConsumers() error {
198212
o.opts.Logger.Println("flushing consumers")
199213
defer o.opts.Logger.Println("flushed consumers")
@@ -238,12 +252,12 @@ func (o *outputProducer) flushConsumers() error {
238252
return nil
239253
}
240254

241-
func (o *outputProducer) addConsumer(tt *TermTest, consume consumer, opts ...SetConsOpt) (*outputConsumer, error) {
255+
func (o *outputProducer) addConsumer(consume consumer, opts ...SetConsOpt) (*outputConsumer, error) {
242256
o.opts.Logger.Printf("adding consumer")
243257
defer o.opts.Logger.Printf("added consumer")
244258

245259
opts = append(opts, OptConsInherit(o.opts))
246-
listener := newOutputConsumer(tt, consume, opts...)
260+
listener := newOutputConsumer(consume, opts...)
247261
o.consumers = append(o.consumers, listener)
248262

249263
if err := o.flushConsumers(); err != nil {

termtest.go

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ type TermTest struct {
2424
outputProducer *outputProducer
2525
listenError chan error
2626
opts *Opts
27-
exited *cmdExit
2827
}
2928

3029
type ErrorHandler func(*TermTest, error) error
@@ -238,10 +237,6 @@ func (tt *TermTest) start() (rerr error) {
238237
}()
239238
wg.Wait()
240239

241-
go func() {
242-
tt.exited = <-waitForCmdExit(tt.cmd)
243-
}()
244-
245240
return nil
246241
}
247242

@@ -324,28 +319,6 @@ func (tt *TermTest) SendCtrlC() {
324319
tt.Send(string([]byte{0x03})) // 0x03 is ASCII character for ^C
325320
}
326321

327-
// Exited returns a channel that sends the given termtest's command cmdExit info when available.
328-
// This can be used within a select{} statement.
329-
// If waitExtra is given, waits a little bit before sending cmdExit info. This allows any fellow
330-
// switch cases with output consumers to handle unprocessed stdout. If there are no such cases
331-
// (e.g. ExpectExit(), where we want to catch an exit ASAP), waitExtra should be false.
332-
func (tt *TermTest) Exited(waitExtra bool) chan *cmdExit {
333-
return waitChan(func() *cmdExit {
334-
ticker := time.NewTicker(processExitPollInterval)
335-
for {
336-
select {
337-
case <-ticker.C:
338-
if tt.exited != nil {
339-
if waitExtra { // allow sibling output consumer cases to handle their output
340-
time.Sleep(processExitExtraWait)
341-
}
342-
return tt.exited
343-
}
344-
}
345-
}
346-
})
347-
}
348-
349322
func (tt *TermTest) errorHandler(rerr *error) {
350323
err := *rerr
351324
if err == nil {

0 commit comments

Comments
 (0)