280 lines
6.0 KiB
Go
280 lines
6.0 KiB
Go
package process
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// LineSubscriber receives individual log lines as they arrive.
|
|
type LineSubscriber func(line string)
|
|
|
|
// ManagedProcess wraps a running child process with log streaming.
|
|
type ManagedProcess struct {
|
|
mu sync.RWMutex
|
|
cmd *exec.Cmd
|
|
PID int
|
|
ProjectID uint
|
|
Status string // "running", "stopped"
|
|
subscribers []LineSubscriber
|
|
logFile *os.File
|
|
cancel chan struct{}
|
|
}
|
|
|
|
// Manager manages multiple running processes keyed by project ID.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
procs map[uint]*ManagedProcess // projectID -> process
|
|
stopChan chan struct{}
|
|
}
|
|
|
|
// NewManager creates a new process Manager.
|
|
func NewManager() *Manager {
|
|
return &Manager{
|
|
procs: make(map[uint]*ManagedProcess),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start launches a command for the given project and streams stdout/stderr.
|
|
func (m *Manager) Start(projectID uint, workDir string, command string, envVars map[string]string, logWriter io.Writer) (*ManagedProcess, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Stop existing process for this project if any
|
|
if existing, ok := m.procs[projectID]; ok {
|
|
existing.Stop()
|
|
}
|
|
|
|
// Parse command: support simple "cmd arg1 arg2" or shell execution
|
|
var cmd *exec.Cmd
|
|
if isShellCommand(command) {
|
|
cmd = exec.Command("sh", "-c", command)
|
|
} else {
|
|
parts := splitCommand(command)
|
|
cmd = exec.Command(parts[0], parts[1:]...)
|
|
}
|
|
|
|
cmd.Dir = workDir
|
|
cmd.Env = os.Environ()
|
|
for k, v := range envVars {
|
|
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
|
|
// Pipe stdout and stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("failed to start process: %w", err)
|
|
}
|
|
|
|
mp := &ManagedProcess{
|
|
cmd: cmd,
|
|
PID: cmd.Process.Pid,
|
|
ProjectID: projectID,
|
|
Status: "running",
|
|
cancel: make(chan struct{}),
|
|
}
|
|
|
|
// Stream stdout/stderr to subscribers and log writer
|
|
go mp.streamOutput(stdout, logWriter)
|
|
go mp.streamOutput(stderr, logWriter)
|
|
|
|
// Monitor process exit
|
|
go func() {
|
|
err := cmd.Wait()
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
mp.mu.Lock()
|
|
mp.Status = "stopped"
|
|
if err != nil {
|
|
mp.broadcast(fmt.Sprintf("[Process exited with error: %v]", err))
|
|
} else {
|
|
mp.broadcast("[Process exited successfully]")
|
|
}
|
|
mp.mu.Unlock()
|
|
delete(m.procs, projectID)
|
|
}()
|
|
|
|
m.procs[projectID] = mp
|
|
return mp, nil
|
|
}
|
|
|
|
// Stop terminates a running process by project ID.
|
|
func (m *Manager) Stop(projectID uint) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
mp, ok := m.procs[projectID]
|
|
if !ok {
|
|
return fmt.Errorf("no running process for project %d", projectID)
|
|
}
|
|
return mp.Stop()
|
|
}
|
|
|
|
// Stop terminates the managed process.
|
|
func (mp *ManagedProcess) Stop() error {
|
|
mp.mu.Lock()
|
|
defer mp.mu.Unlock()
|
|
|
|
if mp.Status == "stopped" {
|
|
return nil
|
|
}
|
|
|
|
close(mp.cancel)
|
|
|
|
// Try graceful shutdown first
|
|
if err := mp.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
// On Windows, SIGTERM is not supported; use Kill
|
|
mp.cmd.Process.Kill()
|
|
return nil
|
|
}
|
|
|
|
// Wait with timeout
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := mp.cmd.Process.Wait()
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
mp.Status = "stopped"
|
|
return nil
|
|
case <-time.After(10 * time.Second):
|
|
mp.cmd.Process.Kill()
|
|
mp.Status = "stopped"
|
|
return fmt.Errorf("process %d killed after timeout", mp.PID)
|
|
}
|
|
}
|
|
|
|
// GetStatus returns the status of a project's process.
|
|
func (m *Manager) GetStatus(projectID uint) string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
mp, ok := m.procs[projectID]
|
|
if !ok {
|
|
return "stopped"
|
|
}
|
|
|
|
mp.mu.RLock()
|
|
defer mp.mu.RUnlock()
|
|
return mp.Status
|
|
}
|
|
|
|
// GetProcess returns the managed process for a project, or nil.
|
|
func (m *Manager) GetProcess(projectID uint) *ManagedProcess {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.procs[projectID]
|
|
}
|
|
|
|
// Subscribe adds a log line subscriber to a running process.
|
|
func (mp *ManagedProcess) Subscribe(fn LineSubscriber) {
|
|
mp.mu.Lock()
|
|
defer mp.mu.Unlock()
|
|
mp.subscribers = append(mp.subscribers, fn)
|
|
}
|
|
|
|
// UnsubscribeAll removes all subscribers.
|
|
func (mp *ManagedProcess) UnsubscribeAll() {
|
|
mp.mu.Lock()
|
|
defer mp.mu.Unlock()
|
|
mp.subscribers = nil
|
|
}
|
|
|
|
// IsRunning returns whether the process is currently running.
|
|
func (mp *ManagedProcess) IsRunning() bool {
|
|
mp.mu.RLock()
|
|
defer mp.mu.RUnlock()
|
|
return mp.Status == "running"
|
|
}
|
|
|
|
func (mp *ManagedProcess) broadcast(line string) {
|
|
for _, sub := range mp.subscribers {
|
|
sub(line)
|
|
}
|
|
}
|
|
|
|
func (mp *ManagedProcess) streamOutput(r io.Reader, logWriter io.Writer) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
mp.mu.RLock()
|
|
mp.broadcast(line)
|
|
mp.mu.RUnlock()
|
|
if logWriter != nil {
|
|
fmt.Fprintln(logWriter, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shutdown stops all managed processes gracefully.
|
|
func (m *Manager) Shutdown() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for id, mp := range m.procs {
|
|
mp.Stop()
|
|
delete(m.procs, id)
|
|
}
|
|
}
|
|
|
|
// isShellCommand returns true if the command contains shell metacharacters.
|
|
func isShellCommand(cmd string) bool {
|
|
for _, c := range cmd {
|
|
switch c {
|
|
case '|', '&', ';', '$', '>', '<', '`', '*', '?', '[', ']', '(', ')', '{', '}':
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// splitCommand splits a simple command string into parts, respecting quotes.
|
|
func splitCommand(cmd string) []string {
|
|
var parts []string
|
|
var current []rune
|
|
inQuote := false
|
|
quoteChar := rune(0)
|
|
|
|
for _, r := range cmd {
|
|
switch {
|
|
case r == '"' || r == '\'':
|
|
if inQuote && r == quoteChar {
|
|
inQuote = false
|
|
} else if !inQuote {
|
|
inQuote = true
|
|
quoteChar = r
|
|
} else {
|
|
current = append(current, r)
|
|
}
|
|
case r == ' ' && !inQuote:
|
|
if len(current) > 0 {
|
|
parts = append(parts, string(current))
|
|
current = nil
|
|
}
|
|
default:
|
|
current = append(current, r)
|
|
}
|
|
}
|
|
if len(current) > 0 {
|
|
parts = append(parts, string(current))
|
|
}
|
|
return parts
|
|
}
|