health: add more ci cases and fix bugs

This commit is contained in:
fatedier 2018-12-09 21:56:46 +08:00
parent 08c17c3247
commit aea9f9fbcc
14 changed files with 409 additions and 66 deletions

View File

@ -34,7 +34,7 @@ gotest:
go test -v --cover ./utils/... go test -v --cover ./utils/...
ci: ci:
go test -count=1 -v ./tests/... go test -count=1 -p=1 -v ./tests/...
alltest: gotest ci alltest: gotest ci

View File

@ -10,6 +10,8 @@ import (
"github.com/fatedier/frp/models/msg" "github.com/fatedier/frp/models/msg"
"github.com/fatedier/frp/utils/log" "github.com/fatedier/frp/utils/log"
frpNet "github.com/fatedier/frp/utils/net" frpNet "github.com/fatedier/frp/utils/net"
"github.com/fatedier/golib/errors"
) )
const ( const (
@ -55,6 +57,7 @@ type ProxyWrapper struct {
lastSendStartMsg time.Time lastSendStartMsg time.Time
lastStartErr time.Time lastStartErr time.Time
closeCh chan struct{} closeCh chan struct{}
healthNotifyCh chan struct{}
mu sync.RWMutex mu sync.RWMutex
log.Logger log.Logger
@ -70,6 +73,7 @@ func NewProxyWrapper(cfg config.ProxyConf, eventHandler EventHandler, logPrefix
Cfg: cfg, Cfg: cfg,
}, },
closeCh: make(chan struct{}), closeCh: make(chan struct{}),
healthNotifyCh: make(chan struct{}),
handler: eventHandler, handler: eventHandler,
Logger: log.NewPrefixLogger(logPrefix), Logger: log.NewPrefixLogger(logPrefix),
} }
@ -125,6 +129,8 @@ func (pw *ProxyWrapper) Start() {
func (pw *ProxyWrapper) Stop() { func (pw *ProxyWrapper) Stop() {
pw.mu.Lock() pw.mu.Lock()
defer pw.mu.Unlock() defer pw.mu.Unlock()
close(pw.closeCh)
close(pw.healthNotifyCh)
pw.pxy.Close() pw.pxy.Close()
if pw.monitor != nil { if pw.monitor != nil {
pw.monitor.Stop() pw.monitor.Stop()
@ -139,6 +145,10 @@ func (pw *ProxyWrapper) Stop() {
} }
func (pw *ProxyWrapper) checkWorker() { func (pw *ProxyWrapper) checkWorker() {
if pw.monitor != nil {
// let monitor do check request first
time.Sleep(500 * time.Millisecond)
}
for { for {
// check proxy status // check proxy status
now := time.Now() now := time.Now()
@ -178,17 +188,30 @@ func (pw *ProxyWrapper) checkWorker() {
case <-pw.closeCh: case <-pw.closeCh:
return return
case <-time.After(statusCheckInterval): case <-time.After(statusCheckInterval):
case <-pw.healthNotifyCh:
} }
} }
} }
func (pw *ProxyWrapper) statusNormalCallback() { func (pw *ProxyWrapper) statusNormalCallback() {
atomic.StoreUint32(&pw.health, 0) atomic.StoreUint32(&pw.health, 0)
errors.PanicToError(func() {
select {
case pw.healthNotifyCh <- struct{}{}:
default:
}
})
pw.Info("health check success") pw.Info("health check success")
} }
func (pw *ProxyWrapper) statusFailedCallback() { func (pw *ProxyWrapper) statusFailedCallback() {
atomic.StoreUint32(&pw.health, 1) atomic.StoreUint32(&pw.health, 1)
errors.PanicToError(func() {
select {
case pw.healthNotifyCh <- struct{}{}:
default:
}
})
pw.Info("health check failed") pw.Info("health check failed")
} }

View File

@ -166,6 +166,11 @@ func parseClientCommonCfgFromCmd() (err error) {
g.GlbClientCfg.LogLevel = logLevel g.GlbClientCfg.LogLevel = logLevel
g.GlbClientCfg.LogFile = logFile g.GlbClientCfg.LogFile = logFile
g.GlbClientCfg.LogMaxDays = int64(logMaxDays) g.GlbClientCfg.LogMaxDays = int64(logMaxDays)
if logFile == "console" {
g.GlbClientCfg.LogWay = "console"
} else {
g.GlbClientCfg.LogWay = "file"
}
return nil return nil
} }

View File

@ -52,7 +52,6 @@ var (
dashboardPwd string dashboardPwd string
assetsDir string assetsDir string
logFile string logFile string
logWay string
logLevel string logLevel string
logMaxDays int64 logMaxDays int64
token string token string
@ -81,7 +80,6 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&dashboardUser, "dashboard_user", "", "admin", "dashboard user") rootCmd.PersistentFlags().StringVarP(&dashboardUser, "dashboard_user", "", "admin", "dashboard user")
rootCmd.PersistentFlags().StringVarP(&dashboardPwd, "dashboard_pwd", "", "admin", "dashboard password") rootCmd.PersistentFlags().StringVarP(&dashboardPwd, "dashboard_pwd", "", "admin", "dashboard password")
rootCmd.PersistentFlags().StringVarP(&logFile, "log_file", "", "console", "log file") rootCmd.PersistentFlags().StringVarP(&logFile, "log_file", "", "console", "log file")
rootCmd.PersistentFlags().StringVarP(&logWay, "log_way", "", "console", "log way")
rootCmd.PersistentFlags().StringVarP(&logLevel, "log_level", "", "info", "log level") rootCmd.PersistentFlags().StringVarP(&logLevel, "log_level", "", "info", "log level")
rootCmd.PersistentFlags().Int64VarP(&logMaxDays, "log_max_days", "", 3, "log_max_days") rootCmd.PersistentFlags().Int64VarP(&logMaxDays, "log_max_days", "", 3, "log_max_days")
rootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "auth token") rootCmd.PersistentFlags().StringVarP(&token, "token", "t", "", "auth token")
@ -175,7 +173,6 @@ func parseServerCommonCfgFromCmd() (err error) {
g.GlbServerCfg.DashboardUser = dashboardUser g.GlbServerCfg.DashboardUser = dashboardUser
g.GlbServerCfg.DashboardPwd = dashboardPwd g.GlbServerCfg.DashboardPwd = dashboardPwd
g.GlbServerCfg.LogFile = logFile g.GlbServerCfg.LogFile = logFile
g.GlbServerCfg.LogWay = logWay
g.GlbServerCfg.LogLevel = logLevel g.GlbServerCfg.LogLevel = logLevel
g.GlbServerCfg.LogMaxDays = logMaxDays g.GlbServerCfg.LogMaxDays = logMaxDays
g.GlbServerCfg.Token = token g.GlbServerCfg.Token = token
@ -194,6 +191,12 @@ func parseServerCommonCfgFromCmd() (err error) {
} }
} }
g.GlbServerCfg.MaxPortsPerClient = maxPortsPerClient g.GlbServerCfg.MaxPortsPerClient = maxPortsPerClient
if logFile == "console" {
g.GlbClientCfg.LogWay = "console"
} else {
g.GlbClientCfg.LogWay = "file"
}
return return
} }

View File

@ -77,6 +77,8 @@ group_key = 123456
# frpc will connect local service's port to detect it's healthy status # frpc will connect local service's port to detect it's healthy status
health_check_type = tcp health_check_type = tcp
health_check_interval_s = 10 health_check_interval_s = 10
health_check_max_failed = 1
health_check_timeout_s = 3
[ssh_random] [ssh_random]
type = tcp type = tcp

View File

@ -174,6 +174,13 @@ func (cfg *BaseProxyConf) UnmarshalFromIni(prefix string, name string, section i
if cfg.HealthCheckType == "tcp" && cfg.Plugin == "" { if cfg.HealthCheckType == "tcp" && cfg.Plugin == "" {
cfg.HealthCheckAddr = cfg.LocalIp + fmt.Sprintf(":%d", cfg.LocalPort) cfg.HealthCheckAddr = cfg.LocalIp + fmt.Sprintf(":%d", cfg.LocalPort)
} }
if cfg.HealthCheckType == "http" && cfg.Plugin == "" && cfg.HealthCheckUrl != "" {
s := fmt.Sprintf("http://%s:%d", cfg.LocalIp, cfg.LocalPort)
if !strings.HasPrefix(cfg.HealthCheckUrl, "/") {
s += "/"
}
cfg.HealthCheckUrl = s + cfg.HealthCheckUrl
}
return nil return nil
} }

View File

@ -0,0 +1,247 @@
package health
import (
"net/http"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/fatedier/frp/tests/config"
"github.com/fatedier/frp/tests/consts"
"github.com/fatedier/frp/tests/mock"
"github.com/fatedier/frp/tests/util"
"github.com/stretchr/testify/assert"
)
const FRPS_CONF = `
[common]
bind_addr = 0.0.0.0
bind_port = 14000
vhost_http_port = 14000
log_file = console
log_level = debug
token = 123456
`
const FRPC_CONF = `
[common]
server_addr = 127.0.0.1
server_port = 14000
log_file = console
log_level = debug
token = 123456
[tcp1]
type = tcp
local_port = 15001
remote_port = 15000
group = test
group_key = 123
health_check_type = tcp
health_check_interval_s = 1
[tcp2]
type = tcp
local_port = 15002
remote_port = 15000
group = test
group_key = 123
health_check_type = tcp
health_check_interval_s = 1
[http1]
type = http
local_port = 15003
custom_domains = test1.com
health_check_type = http
health_check_interval_s = 1
health_check_url = /health
[http2]
type = http
local_port = 15004
custom_domains = test2.com
health_check_type = http
health_check_interval_s = 1
health_check_url = /health
`
func TestHealthCheck(t *testing.T) {
assert := assert.New(t)
// ****** start backgroud services ******
echoSvc1 := mock.NewEchoServer(15001, 1, "echo1")
err := echoSvc1.Start()
if assert.NoError(err) {
defer echoSvc1.Stop()
}
echoSvc2 := mock.NewEchoServer(15002, 1, "echo2")
err = echoSvc2.Start()
if assert.NoError(err) {
defer echoSvc2.Stop()
}
var healthMu sync.RWMutex
svc1Health := true
svc2Health := true
httpSvc1 := mock.NewHttpServer(15003, func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "health") {
healthMu.RLock()
defer healthMu.RUnlock()
if svc1Health {
w.WriteHeader(200)
} else {
w.WriteHeader(500)
}
} else {
w.Write([]byte("http1"))
}
})
err = httpSvc1.Start()
if assert.NoError(err) {
defer httpSvc1.Stop()
}
httpSvc2 := mock.NewHttpServer(15004, func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "health") {
healthMu.RLock()
defer healthMu.RUnlock()
if svc2Health {
w.WriteHeader(200)
} else {
w.WriteHeader(500)
}
} else {
w.Write([]byte("http2"))
}
})
err = httpSvc2.Start()
if assert.NoError(err) {
defer httpSvc2.Stop()
}
time.Sleep(200 * time.Millisecond)
// ****** start frps and frpc ******
frpsCfgPath, err := config.GenerateConfigFile(consts.FRPS_NORMAL_CONFIG, FRPS_CONF)
if assert.NoError(err) {
defer os.Remove(frpsCfgPath)
}
frpcCfgPath, err := config.GenerateConfigFile(consts.FRPC_NORMAL_CONFIG, FRPC_CONF)
if assert.NoError(err) {
defer os.Remove(frpcCfgPath)
}
frpsProcess := util.NewProcess(consts.FRPS_SUB_BIN_PATH, []string{"-c", frpsCfgPath})
err = frpsProcess.Start()
if assert.NoError(err) {
defer frpsProcess.Stop()
}
time.Sleep(100 * time.Millisecond)
frpcProcess := util.NewProcess(consts.FRPC_SUB_BIN_PATH, []string{"-c", frpcCfgPath})
err = frpcProcess.Start()
if assert.NoError(err) {
defer frpcProcess.Stop()
}
time.Sleep(1000 * time.Millisecond)
// ****** healcheck type tcp ******
// echo1 and echo2 is ok
result := make([]string, 0)
res, err := util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
res, err = util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
assert.Contains(result, "echo1")
assert.Contains(result, "echo2")
// close echo2 server, echo1 is work
echoSvc2.Stop()
time.Sleep(1200 * time.Millisecond)
result = make([]string, 0)
res, err = util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
res, err = util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
assert.NotContains(result, "echo2")
// resume echo2 server, all services are ok
echoSvc2 = mock.NewEchoServer(15002, 1, "echo2")
err = echoSvc2.Start()
if assert.NoError(err) {
defer echoSvc2.Stop()
}
time.Sleep(1200 * time.Millisecond)
result = make([]string, 0)
res, err = util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
res, err = util.SendTcpMsg("127.0.0.1:15000", "echo")
assert.NoError(err)
result = append(result, res)
assert.Contains(result, "echo1")
assert.Contains(result, "echo2")
// ****** healcheck type http ******
// http1 and http2 is ok
code, body, _, err := util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test1.com", nil, "")
assert.NoError(err)
assert.Equal(200, code)
assert.Equal("http1", body)
code, body, _, err = util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test2.com", nil, "")
assert.NoError(err)
assert.Equal(200, code)
assert.Equal("http2", body)
// http2 health check error
healthMu.Lock()
svc2Health = false
healthMu.Unlock()
time.Sleep(1200 * time.Millisecond)
code, body, _, err = util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test1.com", nil, "")
assert.NoError(err)
assert.Equal(200, code)
assert.Equal("http1", body)
code, _, _, err = util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test2.com", nil, "")
assert.NoError(err)
assert.Equal(404, code)
// resume http2 service, http1 and http2 are ok
healthMu.Lock()
svc2Health = true
healthMu.Unlock()
time.Sleep(1200 * time.Millisecond)
code, body, _, err = util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test1.com", nil, "")
assert.NoError(err)
assert.Equal(200, code)
assert.Equal("http1", body)
code, body, _, err = util.SendHttpMsg("GET", "http://127.0.0.1:14000/xxx", "test2.com", nil, "")
assert.NoError(err)
assert.Equal(200, code)
assert.Equal("http2", body)
}

View File

@ -22,13 +22,21 @@ import (
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
go mock.StartTcpEchoServer(consts.TEST_TCP_PORT) var err error
go mock.StartTcpEchoServer2(consts.TEST_TCP2_PORT) tcpEcho1 := mock.NewEchoServer(consts.TEST_TCP_PORT, 1, "")
tcpEcho2 := mock.NewEchoServer(consts.TEST_TCP2_PORT, 2, "")
if err = tcpEcho1.Start(); err != nil {
panic(err)
}
if err = tcpEcho2.Start(); err != nil {
panic(err)
}
go mock.StartUdpEchoServer(consts.TEST_UDP_PORT) go mock.StartUdpEchoServer(consts.TEST_UDP_PORT)
go mock.StartUnixDomainServer(consts.TEST_UNIX_DOMAIN_ADDR) go mock.StartUnixDomainServer(consts.TEST_UNIX_DOMAIN_ADDR)
go mock.StartHttpServer(consts.TEST_HTTP_PORT) go mock.StartHttpServer(consts.TEST_HTTP_PORT)
var err error
p1 := util.NewProcess(consts.FRPS_BIN_PATH, []string{"-c", "./auto_test_frps.ini"}) p1 := util.NewProcess(consts.FRPS_BIN_PATH, []string{"-c", "./auto_test_frps.ini"})
if err = p1.Start(); err != nil { if err = p1.Start(); err != nil {
panic(err) panic(err)

View File

@ -17,7 +17,6 @@ const FRPS_RECONNECT_CONF = `
bind_addr = 0.0.0.0 bind_addr = 0.0.0.0
bind_port = 20000 bind_port = 20000
log_file = console log_file = console
# debug, info, warn, error
log_level = debug log_level = debug
token = 123456 token = 123456
` `
@ -27,7 +26,6 @@ const FRPC_RECONNECT_CONF = `
server_addr = 127.0.0.1 server_addr = 127.0.0.1
server_port = 20000 server_port = 20000
log_file = console log_file = console
# debug, info, warn, error
log_level = debug log_level = debug
token = 123456 token = 123456
admin_port = 21000 admin_port = 21000

View File

@ -84,7 +84,8 @@ func TestReload(t *testing.T) {
frpcCfgPath, err := config.GenerateConfigFile(consts.FRPC_NORMAL_CONFIG, FRPC_RELOAD_CONF_1) frpcCfgPath, err := config.GenerateConfigFile(consts.FRPC_NORMAL_CONFIG, FRPC_RELOAD_CONF_1)
if assert.NoError(err) { if assert.NoError(err) {
defer os.Remove(frpcCfgPath) rmFile1 := frpcCfgPath
defer os.Remove(rmFile1)
} }
frpsProcess := util.NewProcess(consts.FRPS_BIN_PATH, []string{"-c", frpsCfgPath}) frpsProcess := util.NewProcess(consts.FRPS_BIN_PATH, []string{"-c", frpsCfgPath})
@ -120,7 +121,10 @@ func TestReload(t *testing.T) {
// reload frpc config // reload frpc config
frpcCfgPath, err = config.GenerateConfigFile(consts.FRPC_NORMAL_CONFIG, FRPC_RELOAD_CONF_2) frpcCfgPath, err = config.GenerateConfigFile(consts.FRPC_NORMAL_CONFIG, FRPC_RELOAD_CONF_2)
assert.NoError(err) if assert.NoError(err) {
rmFile2 := frpcCfgPath
defer os.Remove(rmFile2)
}
err = util.ReloadConf("127.0.0.1:21000", "abc", "abc") err = util.ReloadConf("127.0.0.1:21000", "abc", "abc")
assert.NoError(err) assert.NoError(err)

View File

@ -6,6 +6,9 @@ var (
FRPS_BIN_PATH = "../../bin/frps" FRPS_BIN_PATH = "../../bin/frps"
FRPC_BIN_PATH = "../../bin/frpc" FRPC_BIN_PATH = "../../bin/frpc"
FRPS_SUB_BIN_PATH = "../../../bin/frps"
FRPC_SUB_BIN_PATH = "../../../bin/frpc"
FRPS_NORMAL_CONFIG = "./auto_test_frps.ini" FRPS_NORMAL_CONFIG = "./auto_test_frps.ini"
FRPC_NORMAL_CONFIG = "./auto_test_frpc.ini" FRPC_NORMAL_CONFIG = "./auto_test_frpc.ini"

View File

@ -10,40 +10,48 @@ import (
frpNet "github.com/fatedier/frp/utils/net" frpNet "github.com/fatedier/frp/utils/net"
) )
func StartTcpEchoServer(port int) { type EchoServer struct {
l, err := frpNet.ListenTcp("127.0.0.1", port) l frpNet.Listener
if err != nil {
fmt.Printf("echo server listen error: %v\n", err)
return
}
for { port int
c, err := l.Accept() repeatedNum int
if err != nil { specifyStr string
fmt.Printf("echo server accept error: %v\n", err) }
return
}
go echoWorker(c) func NewEchoServer(port int, repeatedNum int, specifyStr string) *EchoServer {
if repeatedNum <= 0 {
repeatedNum = 1
}
return &EchoServer{
port: port,
repeatedNum: repeatedNum,
specifyStr: specifyStr,
} }
} }
func StartTcpEchoServer2(port int) { func (es *EchoServer) Start() error {
l, err := frpNet.ListenTcp("127.0.0.1", port) l, err := frpNet.ListenTcp("127.0.0.1", es.port)
if err != nil { if err != nil {
fmt.Printf("echo server2 listen error: %v\n", err) fmt.Printf("echo server listen error: %v\n", err)
return return err
} }
es.l = l
go func() {
for { for {
c, err := l.Accept() c, err := l.Accept()
if err != nil { if err != nil {
fmt.Printf("echo server2 accept error: %v\n", err)
return return
} }
go echoWorker2(c) go echoWorker(c, es.repeatedNum, es.specifyStr)
} }
}()
return nil
}
func (es *EchoServer) Stop() {
es.l.Close()
} }
func StartUdpEchoServer(port int) { func StartUdpEchoServer(port int) {
@ -60,7 +68,7 @@ func StartUdpEchoServer(port int) {
return return
} }
go echoWorker(c) go echoWorker(c, 1, "")
} }
} }
@ -80,11 +88,11 @@ func StartUnixDomainServer(unixPath string) {
return return
} }
go echoWorker(c) go echoWorker(c, 1, "")
} }
} }
func echoWorker(c net.Conn) { func echoWorker(c net.Conn, repeatedNum int, specifyStr string) {
buf := make([]byte, 2048) buf := make([]byte, 2048)
for { for {
@ -99,28 +107,14 @@ func echoWorker(c net.Conn) {
} }
} }
c.Write(buf[:n]) if specifyStr != "" {
} c.Write([]byte(specifyStr))
}
func echoWorker2(c net.Conn) {
buf := make([]byte, 2048)
for {
n, err := c.Read(buf)
if err != nil {
if err == io.EOF {
c.Close()
break
} else { } else {
fmt.Printf("echo server read error: %v\n", err)
return
}
}
var w []byte var w []byte
for i := 0; i < repeatedNum; i++ {
w = append(w, buf[:n]...) w = append(w, buf[:n]...)
w = append(w, buf[:n]...) }
c.Write(w) c.Write(w)
} }
}
} }

View File

@ -3,6 +3,7 @@ package mock
import ( import (
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@ -12,6 +13,36 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
type HttpServer struct {
l net.Listener
port int
handler http.HandlerFunc
}
func NewHttpServer(port int, handler http.HandlerFunc) *HttpServer {
return &HttpServer{
port: port,
handler: handler,
}
}
func (hs *HttpServer) Start() error {
l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", hs.port))
if err != nil {
fmt.Printf("http server listen error: %v\n", err)
return err
}
hs.l = l
go http.Serve(l, http.HandlerFunc(hs.handler))
return nil
}
func (hs *HttpServer) Stop() {
hs.l.Close()
}
var upgrader = websocket.Upgrader{} var upgrader = websocket.Upgrader{}
func StartHttpServer(port int) { func StartHttpServer(port int) {

View File

@ -1,6 +1,7 @@
package util package util
import ( import (
"bytes"
"context" "context"
"os/exec" "os/exec"
) )
@ -8,15 +9,21 @@ import (
type Process struct { type Process struct {
cmd *exec.Cmd cmd *exec.Cmd
cancel context.CancelFunc cancel context.CancelFunc
errorOutput *bytes.Buffer
beforeStopHandler func()
} }
func NewProcess(path string, params []string) *Process { func NewProcess(path string, params []string) *Process {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, path, params...) cmd := exec.CommandContext(ctx, path, params...)
return &Process{ p := &Process{
cmd: cmd, cmd: cmd,
cancel: cancel, cancel: cancel,
} }
p.errorOutput = bytes.NewBufferString("")
cmd.Stderr = p.errorOutput
return p
} }
func (p *Process) Start() error { func (p *Process) Start() error {
@ -24,6 +31,17 @@ func (p *Process) Start() error {
} }
func (p *Process) Stop() error { func (p *Process) Stop() error {
if p.beforeStopHandler != nil {
p.beforeStopHandler()
}
p.cancel() p.cancel()
return p.cmd.Wait() return p.cmd.Wait()
} }
func (p *Process) ErrorOutput() string {
return p.errorOutput.String()
}
func (p *Process) SetBeforeStopHandler(fn func()) {
p.beforeStopHandler = fn
}