Skip to content

Commit 3108b31

Browse files
Fix issue 94: check if Stopped called after each BeforeExec func, and update relevant docs
1 parent 0986371 commit 3108b31

File tree

2 files changed

+87
-12
lines changed

2 files changed

+87
-12
lines changed

cmd.go

+36-12
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ type Options struct {
167167

168168
// BeforeExec is a list of functions called immediately before starting
169169
// the real command. These functions can be used to customize the underlying
170-
// os/exec.Cmd. For example, to set SysProcAttr.
170+
// os/exec.Cmd. For example, to set SysProcAttr. If Stop is called while
171+
// executing these functions, Start (or StartWithStdin) returns after the
172+
// currently executing function returns. Stop does not stop these functions,
173+
// but a future version will pass a context for cancellation.
171174
BeforeExec []func(cmd *exec.Cmd)
172175

173176
// LineBufferSize sets the size of the OutputStream line buffer. The default
@@ -285,24 +288,41 @@ func (c *Cmd) StartWithStdin(in io.Reader) <-chan Status {
285288
if c.statusChan != nil {
286289
return c.statusChan
287290
}
288-
289291
c.statusChan = make(chan Status, 1)
292+
290293
go c.run(in)
291294
return c.statusChan
292295
}
293296

294297
// Stop stops the command by sending its process group a SIGTERM signal.
295298
// Stop is idempotent. Stopping and already stopped command returns nil.
296299
//
297-
// Stop returns ErrNotStarted if called before Start or StartWithStdin. If the
298-
// command is very slow to start, Stop can return ErrNotStarted after calling
299-
// Start or StartWithStdin because this package is still waiting for the system
300-
// to start the process. All other return errors are from the low-level system
301-
// function for process termination.
300+
// Stop returns ErrNotStarted if:
301+
// 1. Start (or StartWithStdin) was not called yet, or
302+
// 2. Start was called but Stop was called before starting the command, or
303+
// 3. Start was called but the system is still starting the command
304+
//
305+
// The second case can happen if Stop is called while executing Options.BeforeExec
306+
// functions. In this case, Status.Exit = -1 and other Status fields are zero values.
307+
// The third case is a race condition that might be fixed in future versions of
308+
// this package. It means you should not rely on Stop immediately after calling Start;
309+
// instead, call Start and wait for Status.PID to be set, which signals that the system
310+
// has completely started the command.
311+
//
312+
// All other return errors are from the low-level system function for process termination.
302313
func (c *Cmd) Stop() error {
303314
c.Lock()
304315
defer c.Unlock()
305316

317+
// Flag that command was stopped, it didn't complete. This results in
318+
// status.Complete = false. If beforeExecFuncs are executing (Cmd hasn't
319+
// started yet), run will return after the current func returns (fixes
320+
// bug https://github.com/go-cmd/cmd/issues/94).
321+
if c.stopped {
322+
return nil
323+
}
324+
c.stopped = true
325+
306326
// c.statusChan is created in StartWithStdin()/Start(), so if nil the caller
307327
// hasn't started the command yet. c.started is set true in run() only after
308328
// the underlying os/exec.Cmd.Start() has returned without an error, so we're
@@ -319,10 +339,6 @@ func (c *Cmd) Stop() error {
319339
return nil
320340
}
321341

322-
// Flag that command was stopped, it didn't complete. This results in
323-
// status.Complete = false
324-
c.stopped = true
325-
326342
// Signal the process group (-pid), not just the process, so that the process
327343
// and all its children are signaled. Else, child procs can keep running and
328344
// keep the stdout/stderr fd open and cause cmd.Wait to hang.
@@ -412,7 +428,6 @@ func (c *Cmd) run(in io.Reader) {
412428
// Set exec.Cmd.Stdout and .Stderr to our concurrent-safe stdout/stderr
413429
// buffer, stream both, or neither
414430
switch {
415-
416431
case c.stdoutBuf != nil && c.stderrBuf != nil && c.stdoutStream != nil: // buffer and stream
417432
cmd.Stdout = io.MultiWriter(c.stdoutStream, c.stdoutBuf)
418433
cmd.Stderr = io.MultiWriter(c.stderrStream, c.stderrBuf)
@@ -459,6 +474,15 @@ func (c *Cmd) run(in io.Reader) {
459474
// Run all optional commands to customize underlying os/exe.Cmd.
460475
for _, f := range c.beforeExecFuncs {
461476
f(cmd)
477+
478+
// Return early if Stop called
479+
// https://github.com/go-cmd/cmd/issues/94
480+
c.Lock()
481+
stopped := c.stopped
482+
c.Unlock()
483+
if stopped {
484+
return
485+
}
462486
}
463487

464488
// //////////////////////////////////////////////////////////////////////

cmd_test.go

+51
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,57 @@ func TestOptionsBeforeExec(t *testing.T) {
13691369
}
13701370
}
13711371

1372+
func TestOptionsBeforeExecButStopped(t *testing.T) {
1373+
// https://github.com/go-cmd/cmd/issues/94
1374+
// Bug: if cmd is stopped before BeforeExec completes, cmd still runs.
1375+
// To test, call Stop before BeforeExec callbacks are done and make sure
1376+
// the cmd is not run.
1377+
called := make(chan bool)
1378+
p := cmd.NewCmdOptions(
1379+
cmd.Options{
1380+
CombinedOutput: true,
1381+
BeforeExec: []func(cmd *exec.Cmd){
1382+
func(cmd *exec.Cmd) {
1383+
called <- true // let test call Stop
1384+
<-called // wait for that ^
1385+
},
1386+
},
1387+
},
1388+
"/bin/ls",
1389+
)
1390+
statusChan := p.Start()
1391+
select {
1392+
case <-called:
1393+
case <-time.After(2 * time.Second):
1394+
t.Fatal("timeout waiting for BeforeExec func")
1395+
}
1396+
1397+
err := p.Stop()
1398+
if err != cmd.ErrNotStarted {
1399+
t.Errorf("got err %v, expected cmd.ErrNotStarted", err)
1400+
}
1401+
close(called) // unblock BeforeExec func
1402+
1403+
var got cmd.Status
1404+
select {
1405+
case got = <-statusChan:
1406+
case <-time.After(2 * time.Second):
1407+
t.Fatal("timeout waiting for cmd to return")
1408+
}
1409+
1410+
t.Logf("%+v\n", got)
1411+
if len(got.Stdout) != 0 {
1412+
t.Errorf("cmd ran, expected no output: %v", got.Stdout)
1413+
}
1414+
1415+
// Double checking that 2nd Stop returns nil because Stop docs
1416+
// say "stopping an already stopped command returns nil".
1417+
err = p.Stop()
1418+
if err != nil {
1419+
t.Errorf("got err %v, expected nil", err)
1420+
}
1421+
}
1422+
13721423
func TestCmdLineBufferIncrease(t *testing.T) {
13731424
lineContent := cmd.DEFAULT_LINE_BUFFER_SIZE * 2
13741425
longLine := make([]byte, lineContent) // "AAA..."

0 commit comments

Comments
 (0)