diff --git a/cmd.go b/cmd.go index 46787dc..63da07d 100644 --- a/cmd.go +++ b/cmd.go @@ -58,17 +58,15 @@ import ( // should not be modified, except Env which can be set before calling Start. // To create a new Cmd, call NewCmd or NewCmdOptions. type Cmd struct { - Name string - Args []string - Env []string - Dir string - Stdout chan string // streaming STDOUT if enabled, else nil (see Options) - Stderr chan string // streaming STDERR if enabled, else nil (see Options) *sync.Mutex - started bool // cmd.Start called, no error - stopped bool // Stop called - done bool // run() done - final bool // status finalized in Status + stateLock *sync.Mutex + Name string + Args []string + Env []string + Dir string + State CmdState // The state of the cmd (stopped, started, etc) + Stdout chan string // streaming STDOUT if enabled, else nil (see Options) + Stderr chan string // streaming STDERR if enabled, else nil (see Options) startTime time.Time // if started true stdout *OutputBuffer // low-level stdout buffering and streaming stderr *OutputBuffer // low-level stderr buffering and streaming @@ -94,16 +92,15 @@ type Cmd struct { // command failed. Callers should check Error first. If nil, then check Exit and // Complete. type Status struct { - Cmd string - PID int - Complete bool // false if stopped or signaled - Exit int // exit code of process - Error error // Go error - StartTs int64 // Unix ts (nanoseconds), zero if Cmd not started - StopTs int64 // Unix ts (nanoseconds), zero if Cmd not started or running - Runtime float64 // seconds, zero if Cmd not started - Stdout []string // buffered STDOUT; see Cmd.Status for more info - Stderr []string // buffered STDERR; see Cmd.Status for more info + Cmd string + PID int + Exit int // exit code of process + Error error // Go error + StartTs int64 // Unix ts (nanoseconds), zero if Cmd not started + StopTs int64 // Unix ts (nanoseconds), zero if Cmd not started or running + Runtime float64 // seconds, zero if Cmd not started + Stdout []string // buffered STDOUT; see Cmd.Status for more info + Stderr []string // buffered STDERR; see Cmd.Status for more info } // NewCmd creates a new Cmd for the given command name and arguments. The command @@ -111,17 +108,17 @@ type Status struct { // is off. To control output, use NewCmdOptions instead. func NewCmd(name string, args ...string) *Cmd { return &Cmd{ - Name: name, - Args: args, - buffered: true, - Mutex: &sync.Mutex{}, + Name: name, + Args: args, + buffered: true, + Mutex: &sync.Mutex{}, + stateLock: &sync.Mutex{}, status: Status{ - Cmd: name, - PID: 0, - Complete: false, - Exit: -1, - Error: nil, - Runtime: 0, + Cmd: name, + PID: 0, + Exit: -1, + Error: nil, + Runtime: 0, }, doneChan: make(chan struct{}), } @@ -190,6 +187,7 @@ func (c *Cmd) Start() <-chan Status { c.Lock() defer c.Unlock() + // Cannot Start if it's already started if c.statusChan != nil { return c.statusChan } @@ -209,13 +207,12 @@ func (c *Cmd) Stop() error { // Nothing to stop if Start hasn't been called, or the proc hasn't started, // or it's already done. - if c.statusChan == nil || !c.started || c.done { + if c.statusChan == nil || c.IsInitialState() || c.IsFinalState() { return nil } - // Flag that command was stopped, it didn't complete. This results in - // status.Complete = false - c.stopped = true + // Flag that command was stopped, it didn't complete. + c.setState(STOPPING) // Signal the process group (-pid), not just the process, so that the process // and all its children are signaled. Else, child procs can keep running and @@ -245,27 +242,28 @@ func (c *Cmd) Status() Status { defer c.Unlock() // Return default status if cmd hasn't been started - if c.statusChan == nil || !c.started { + if c.statusChan == nil || c.IsInitialState() { return c.status } - if c.done { - // No longer running - if !c.final { - if c.buffered { - c.status.Stdout = c.stdout.Lines() - c.status.Stderr = c.stderr.Lines() - c.stdout = nil // release buffers - c.stderr = nil - } - c.final = true + if c.IsFinalState() { + // No longer running and the cmd buffer wasn't flushed + if c.buffered && c.status.Stdout == nil { + c.status.Stdout = c.stdout.Lines() + c.status.Stderr = c.stderr.Lines() + c.stdout = nil // release buffers + c.stderr = nil } } else { // Still running c.status.Runtime = time.Now().Sub(c.startTime).Seconds() if c.buffered { - c.status.Stdout = c.stdout.Lines() - c.status.Stderr = c.stderr.Lines() + if c.stdout != nil { + c.status.Stdout = c.stdout.Lines() + } + if c.stderr != nil { + c.status.Stderr = c.stderr.Lines() + } } } @@ -274,19 +272,58 @@ func (c *Cmd) Status() Status { // Done returns a channel that's closed when the command stops running. // This method is useful for multiple goroutines to wait for the command -// to finish.Call Status after the command finishes to get its final status. +// to finish. Call Status after the command finishes to get its final status. func (c *Cmd) Done() <-chan struct{} { return c.doneChan } +// IsInitialState returns true if the Cmd is in the initial state. +func (c *Cmd) IsInitialState() bool { + c.stateLock.Lock() + defer c.stateLock.Unlock() + return c.State == INITIAL +} + +// IsFinalState returns true if the Cmd is in a final state. +// Final states are definitive and cannot be exited from. +func (c *Cmd) IsFinalState() bool { + c.stateLock.Lock() + defer c.stateLock.Unlock() + return c.State == INTERRUPT || c.State == FINISHED || c.State == FATAL +} + // -------------------------------------------------------------------------- +// setState sets the new internal state and might be used to trigger events. +// It has a minimal validation of states. +func (c *Cmd) setState(state CmdState) { + // If the new state is the old state, skip + // Final states cannot be changed, skip + if c.State == state || c.IsFinalState() { + return + } else if c.IsInitialState() { + c.stateLock.Lock() + // The only possible state after "initial" is "starting" + c.State = STARTING + c.stateLock.Unlock() + } else { + c.stateLock.Lock() + c.State = state + c.stateLock.Unlock() + } + // TODO: emit state changes in the future +} + func (c *Cmd) run() { defer func() { c.statusChan <- c.Status() // unblocks Start if caller is waiting close(c.doneChan) }() + c.Lock() + c.setState(STARTING) + c.Unlock() + // ////////////////////////////////////////////////////////////////////// // Setup command // ////////////////////////////////////////////////////////////////////// @@ -335,7 +372,7 @@ func (c *Cmd) run() { c.status.Error = err c.status.StartTs = now.UnixNano() c.status.StopTs = time.Now().UnixNano() - c.done = true + c.setState(FATAL) c.Unlock() return } @@ -345,7 +382,7 @@ func (c *Cmd) run() { c.startTime = now // command is running c.status.PID = cmd.Process.Pid // command is running c.status.StartTs = now.UnixNano() - c.started = true + c.setState(RUNNING) c.Unlock() // ////////////////////////////////////////////////////////////////////// @@ -353,12 +390,15 @@ func (c *Cmd) run() { // ////////////////////////////////////////////////////////////////////// err := cmd.Wait() now = time.Now() + exitCode := 0 + + // Set final status + c.Lock() + defer c.Unlock() // Get exit code of the command. According to the manual, Wait() returns: // "If the command fails to run or doesn't complete successfully, the error // is of type *ExitError. Other error types may be returned for I/O problems." - exitCode := 0 - signaled := false if err != nil && fmt.Sprintf("%T", err) == "*exec.ExitError" { // This is the normal case which is not really an error. It's string // representation is only "*exec.ExitError". It only means the cmd @@ -373,23 +413,17 @@ func (c *Cmd) run() { if waitStatus, ok := exiterr.Sys().(syscall.WaitStatus); ok { exitCode = waitStatus.ExitStatus() // -1 if signaled if waitStatus.Signaled() { - signaled = true err = errors.New(exiterr.Error()) // "signal: terminated" + c.setState(INTERRUPT) } } } - // Set final status - c.Lock() - if !c.stopped && !signaled { - c.status.Complete = true - } c.status.Runtime = now.Sub(c.startTime).Seconds() c.status.StopTs = now.UnixNano() c.status.Exit = exitCode c.status.Error = err - c.done = true - c.Unlock() + c.setState(FINISHED) } // ////////////////////////////////////////////////////////////////////////// diff --git a/cmd_test.go b/cmd_test.go index 1bde53e..ee8ba8f 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -19,14 +19,13 @@ func TestCmdOK(t *testing.T) { p := cmd.NewCmd("echo", "foo") gotStatus := <-p.Start() expectStatus := cmd.Status{ - Cmd: "echo", - PID: gotStatus.PID, // nondeterministic - Complete: true, - Exit: 0, - Error: nil, - Runtime: gotStatus.Runtime, // nondeterministic - Stdout: []string{"foo"}, - Stderr: []string{}, + Cmd: "echo", + PID: gotStatus.PID, // nondeterministic + Exit: 0, + Error: nil, + Runtime: gotStatus.Runtime, // nondeterministic + Stdout: []string{"foo"}, + Stderr: []string{}, } if gotStatus.StartTs < now { t.Error("StartTs < now") @@ -71,14 +70,13 @@ func TestCmdNonzeroExit(t *testing.T) { p := cmd.NewCmd("false") gotStatus := <-p.Start() expectStatus := cmd.Status{ - Cmd: "false", - PID: gotStatus.PID, // nondeterministic - Complete: true, - Exit: 1, - Error: nil, - Runtime: gotStatus.Runtime, // nondeterministic - Stdout: []string{}, - Stderr: []string{}, + Cmd: "false", + PID: gotStatus.PID, // nondeterministic + Exit: 1, + Error: nil, + Runtime: gotStatus.Runtime, // nondeterministic + Stdout: []string{}, + Stderr: []string{}, } gotStatus.StartTs = 0 gotStatus.StopTs = 0 @@ -131,14 +129,13 @@ func TestCmdStop(t *testing.T) { gotStatus.StopTs = 0 expectStatus := cmd.Status{ - Cmd: "./test/count-and-sleep", - PID: gotStatus.PID, // nondeterministic - Complete: false, // signaled by Stop - Exit: -1, // signaled by Stop - Error: errors.New("signal: terminated"), // signaled by Stop - Runtime: gotStatus.Runtime, // nondeterministic - Stdout: []string{"1"}, - Stderr: []string{}, + Cmd: "./test/count-and-sleep", + PID: gotStatus.PID, // nondeterministic + Exit: -1, // signaled by Stop + Error: errors.New("signal: terminated"), // signaled by Stop + Runtime: gotStatus.Runtime, // nondeterministic + Stdout: []string{"1"}, + Stderr: []string{}, } if diffs := deep.Equal(gotStatus, expectStatus); diffs != nil { t.Error(diffs) @@ -169,14 +166,13 @@ func TestCmdNotStarted(t *testing.T) { gotStatus := p.Status() expectStatus := cmd.Status{ - Cmd: "echo", - PID: 0, - Complete: false, - Exit: -1, - Error: nil, - Runtime: 0, - Stdout: nil, - Stderr: nil, + Cmd: "echo", + PID: 0, + Exit: -1, + Error: nil, + Runtime: 0, + Stdout: nil, + Stderr: nil, } if diffs := deep.Equal(gotStatus, expectStatus); diffs != nil { t.Error(diffs) @@ -259,14 +255,13 @@ func TestCmdNotFound(t *testing.T) { gotStatus.StartTs = 0 gotStatus.StopTs = 0 expectStatus := cmd.Status{ - Cmd: "cmd-does-not-exist", - PID: 0, - Complete: false, - Exit: -1, - Error: errors.New(`exec: "cmd-does-not-exist": executable file not found in $PATH`), - Runtime: 0, - Stdout: nil, - Stderr: nil, + Cmd: "cmd-does-not-exist", + PID: 0, + Exit: -1, + Error: errors.New(`exec: "cmd-does-not-exist": executable file not found in $PATH`), + Runtime: 0, + Stdout: []string{}, + Stderr: []string{}, } if diffs := deep.Equal(gotStatus, expectStatus); diffs != nil { t.Logf("%+v", gotStatus) @@ -308,14 +303,13 @@ func TestCmdLost(t *testing.T) { gotStatus.StopTs = 0 expectStatus := cmd.Status{ - Cmd: "./test/count-and-sleep", - PID: s.PID, - Complete: false, - Exit: -1, - Error: errors.New("signal: killed"), - Runtime: 0, - Stdout: []string{"1"}, - Stderr: []string{}, + Cmd: "./test/count-and-sleep", + PID: s.PID, + Exit: -1, + Error: errors.New("signal: killed"), + Runtime: 0, + Stdout: []string{"1"}, + Stderr: []string{}, } if diffs := deep.Equal(gotStatus, expectStatus); diffs != nil { t.Logf("%+v\n", gotStatus) @@ -979,14 +973,13 @@ func TestCmdEnvOK(t *testing.T) { p.Env = []string{"FOO=foo"} gotStatus := <-p.Start() expectStatus := cmd.Status{ - Cmd: "env", - PID: gotStatus.PID, // nondeterministic - Complete: true, - Exit: 0, - Error: nil, - Runtime: gotStatus.Runtime, // nondeterministic - Stdout: []string{"FOO=foo"}, - Stderr: []string{}, + Cmd: "env", + PID: gotStatus.PID, // nondeterministic + Exit: 0, + Error: nil, + Runtime: gotStatus.Runtime, // nondeterministic + Stdout: []string{"FOO=foo"}, + Stderr: []string{}, } if gotStatus.StartTs < now { t.Error("StartTs < now") diff --git a/state.go b/state.go new file mode 100644 index 0000000..61020c7 --- /dev/null +++ b/state.go @@ -0,0 +1,35 @@ +package cmd + +const ( + INITIAL = iota + STARTING + RUNNING + STOPPING + INTERRUPT // final state (used when stopped or signaled) + FINISHED // final state (used in cas of a natural exit) + FATAL // final state (used in case of error while starting) +) + +// CmdState represents all Cmd states +type CmdState uint8 + +func (p CmdState) String() string { + switch p { + case INITIAL: + return "initial" + case STARTING: + return "starting" + case RUNNING: + return "running" + case STOPPING: + return "stopping" + case INTERRUPT: + return "interrupted" + case FINISHED: + return "finished" + case FATAL: + return "fatal" + default: + return "unknown" + } +} diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..92fb8ea --- /dev/null +++ b/state_test.go @@ -0,0 +1,60 @@ +package cmd_test + +import ( + "testing" + + "github.com/go-cmd/cmd" +) + +func TestState1(t *testing.T) { + var s cmd.CmdState + + s = cmd.INITIAL + if s.String() != "initial" { + t.Errorf("got State %s, expecting %s", s.String(), "initial") + } + + s = cmd.STARTING + if s.String() != "starting" { + t.Errorf("got State %s, expecting %s", s.String(), "starting") + } + + s = cmd.RUNNING + if s.String() != "running" { + t.Errorf("got State %s, expecting %s", s.String(), "running") + } + + s = cmd.STOPPING + if s.String() != "stopping" { + t.Errorf("got State %s, expecting %s", s.String(), "stopping") + } + + s = cmd.INTERRUPT + if s.String() != "interrupted" { + t.Errorf("got State %s, expecting %s", s.String(), "interrupted") + } + + s = cmd.FINISHED + if s.String() != "finished" { + t.Errorf("got State %s, expecting %s", s.String(), "finished") + } + + s = cmd.FATAL + if s.String() != "fatal" { + t.Errorf("got State %s, expecting %s", s.String(), "fatal") + } +} + +func TestState2(t *testing.T) { + var s cmd.CmdState + + s = 0 + if s.String() != "initial" { + t.Errorf("got State %s, expecting %s", s.String(), "initial") + } + + s = 99 + if s.String() != "unknown" { + t.Errorf("got State %s, expecting %s", s.String(), "unknown") + } +}