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 }