diff --git a/packages/browseros-agent/tools/alpha/go.mod b/packages/browseros-agent/tools/alpha/go.mod index c68039e0..055322e9 100644 --- a/packages/browseros-agent/tools/alpha/go.mod +++ b/packages/browseros-agent/tools/alpha/go.mod @@ -3,11 +3,15 @@ module browseros-alpha go 1.25.7 require ( + github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/packages/browseros-agent/tools/alpha/go.sum b/packages/browseros-agent/tools/alpha/go.sum index 47edb24d..491974ae 100644 --- a/packages/browseros-agent/tools/alpha/go.sum +++ b/packages/browseros-agent/tools/alpha/go.sum @@ -1,12 +1,21 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/packages/browseros-agent/tools/alpha/proc/log.go b/packages/browseros-agent/tools/alpha/proc/log.go new file mode 100644 index 00000000..24d9718d --- /dev/null +++ b/packages/browseros-agent/tools/alpha/proc/log.go @@ -0,0 +1,46 @@ +package proc + +import ( + "bufio" + "fmt" + + "github.com/fatih/color" +) + +type Tag struct { + Name string + Color *color.Color +} + +var ( + TagBuild = Tag{"build", color.New(color.FgYellow)} + TagAgent = Tag{"agent", color.New(color.FgMagenta)} + TagServer = Tag{"server", color.New(color.FgCyan)} + TagBrowser = Tag{"browser", color.New(color.FgBlue)} + TagInfo = Tag{"info", color.New(color.FgGreen)} + TagTest = Tag{"test", color.New(color.FgWhite)} + + ErrorColor = color.New(color.FgRed) + WarnColor = color.New(color.FgYellow) + BoldColor = color.New(color.Bold) + DimColor = color.New(color.Faint) +) + +func LogMsg(t Tag, msg string) { + fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg) +} + +func LogMsgf(t Tag, format string, args ...any) { + LogMsg(t, fmt.Sprintf(format, args...)) +} + +func StreamLines(r interface{ Read([]byte) (int, error) }, t Tag) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), line) + } + } +} diff --git a/packages/browseros-agent/tools/alpha/proc/managed.go b/packages/browseros-agent/tools/alpha/proc/managed.go new file mode 100644 index 00000000..21ebb50c --- /dev/null +++ b/packages/browseros-agent/tools/alpha/proc/managed.go @@ -0,0 +1,157 @@ +package proc + +import ( + "context" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" +) + +type ProcConfig struct { + Tag Tag + Dir string + Env []string + Restart bool + Cmd []string + BeforeStart func() error +} + +type ManagedProc struct { + Cfg ProcConfig + cancel context.CancelFunc + mu sync.Mutex + proc *os.Process + exited chan struct{} +} + +func StartManaged(ctx context.Context, wg *sync.WaitGroup, cfg ProcConfig) *ManagedProc { + procCtx, procCancel := context.WithCancel(ctx) + mp := &ManagedProc{ + Cfg: cfg, + cancel: procCancel, + exited: make(chan struct{}), + } + + wg.Add(1) + go func() { + defer wg.Done() + mp.run(procCtx) + }() + + return mp +} + +func (mp *ManagedProc) run(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + + if mp.Cfg.BeforeStart != nil { + if err := mp.Cfg.BeforeStart(); err != nil { + LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err)) + if !mp.Cfg.Restart || ctx.Err() != nil { + return + } + time.Sleep(time.Second) + continue + } + } + + LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " "))) + + cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...) + cmd.Dir = mp.Cfg.Dir + if mp.Cfg.Env != nil { + cmd.Env = mp.Cfg.Env + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Error starting: %v", err)) + if !mp.Cfg.Restart || ctx.Err() != nil { + return + } + time.Sleep(time.Second) + continue + } + + mp.mu.Lock() + mp.proc = cmd.Process + mp.exited = make(chan struct{}) + mp.mu.Unlock() + + var streamWg sync.WaitGroup + streamWg.Add(2) + go func() { defer streamWg.Done(); StreamLines(stdout, mp.Cfg.Tag) }() + go func() { defer streamWg.Done(); StreamLines(stderr, mp.Cfg.Tag) }() + + streamWg.Wait() + _ = cmd.Wait() + + mp.mu.Lock() + mp.proc = nil + close(mp.exited) + mp.mu.Unlock() + + if ctx.Err() != nil { + return + } + + exitCode := cmd.ProcessState.ExitCode() + if exitCode != 0 { + LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Process exited with code %d", exitCode)) + } else { + LogMsg(mp.Cfg.Tag, "Process exited cleanly") + } + + if !mp.Cfg.Restart { + return + } + + LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Restarting in 1s...")) + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func (mp *ManagedProc) Stop() { + mp.cancel() + mp.mu.Lock() + proc := mp.proc + exited := mp.exited + mp.mu.Unlock() + + if proc != nil { + _ = syscall.Kill(-proc.Pid, syscall.SIGTERM) + select { + case <-exited: + case <-time.After(5 * time.Second): + _ = syscall.Kill(-proc.Pid, syscall.SIGKILL) + select { + case <-exited: + case <-time.After(3 * time.Second): + LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Process did not exit after SIGKILL, giving up")) + } + } + } +} + +func (mp *ManagedProc) ForceKill() { + mp.mu.Lock() + proc := mp.proc + mp.mu.Unlock() + + if proc != nil { + _ = syscall.Kill(-proc.Pid, syscall.SIGKILL) + } +} diff --git a/packages/browseros-agent/tools/alpha/proc/run.go b/packages/browseros-agent/tools/alpha/proc/run.go new file mode 100644 index 00000000..d712cfd4 --- /dev/null +++ b/packages/browseros-agent/tools/alpha/proc/run.go @@ -0,0 +1,48 @@ +package proc + +import ( + "context" + "os/exec" + "sync" +) + +func RunBlocking(ctx context.Context, dir string, t Tag, args ...string) error { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = dir + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + return err + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); StreamLines(stdout, t) }() + go func() { defer wg.Done(); StreamLines(stderr, t) }() + wg.Wait() + + return cmd.Wait() +} + +func RunBlockingWithEnv(ctx context.Context, dir string, env []string, t Tag, args ...string) error { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = env + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + + if err := cmd.Start(); err != nil { + return err + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); StreamLines(stdout, t) }() + go func() { defer wg.Done(); StreamLines(stderr, t) }() + wg.Wait() + + return cmd.Wait() +}