package main import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" ) type ProcessManager struct { sync.RWMutex ActiveInstances map[string]*exec.Cmd StdinPipes map[string]io.WriteCloser LogBuffers map[string]*InstanceLogBuffer } type InstanceLogBuffer struct { sync.RWMutex Lines []string MaxLines int } func NewProcessManager() *ProcessManager { return &ProcessManager{ ActiveInstances: make(map[string]*exec.Cmd), StdinPipes: make(map[string]io.WriteCloser), LogBuffers: map[string]*InstanceLogBuffer{}, } } func (pm *ProcessManager) StartInstance(name string, version string, meta InstanceMetadata, config *AppConfig) error { pm.Lock() defer pm.Unlock() if _, running := pm.ActiveInstances[name]; running { return fmt.Errorf("Instance '%s' is already running", name) } binaryPath := filepath.Join(config.Storage.InstallDir, version, "VintagestoryServer") instanceDataPath := filepath.Join(config.Storage.InstancesDir, name) if _, err := os.Stat(binaryPath); os.IsNotExist(err) { fmt.Printf("Server version '%s' not installed, attempting to install\n", version) install_err := DownloadAndExtractServer(version, config.Storage.InstallDir) if install_err != nil { return fmt.Errorf("Could not locate or download a server binary for version '%s'", version) } } cmd := exec.Command(binaryPath, "--dataPath", instanceDataPath) cmd.Env = os.Environ() cmd.Stderr = os.Stderr stdinPipe, err := cmd.StdinPipe() if err != nil { return fmt.Errorf("Failed to open stdin pipe: %w", err) } stdoutPipe, err := cmd.StdoutPipe() if err != nil { stdinPipe.Close() return fmt.Errorf("failed to open stdout conduit: %w", err) } if err := cmd.Start(); err != nil { stdinPipe.Close() stdoutPipe.Close() return fmt.Errorf("Failed to start process thread: %w", err) } pm.ActiveInstances[name] = cmd pm.StdinPipes[name] = stdinPipe pm.LogBuffers[name] = NewInstanceLogBuffer(200) go pm.streamLogs(name, stdoutPipe) go pm.watchProcessExit(name, cmd) fmt.Printf("Server instance '%s' successfully spawned under PID %d\n", name, cmd.Process.Pid) return nil } func (pm *ProcessManager) streamLogs(name string, stdout io.ReadCloser) { defer stdout.Close() scanner := bufio.NewScanner(stdout) for scanner.Scan() { line := scanner.Text() fmt.Printf("[%s]: %s\n", name, line) pm.RLock() buf, exists := pm.LogBuffers[name] pm.RUnlock() if exists { buf.Append(line) } } } func (pm *ProcessManager) watchProcessExit(name string, cmd *exec.Cmd) { _ = cmd.Wait() pm.Lock() defer pm.Unlock() delete(pm.ActiveInstances, name) if pipe, exists := pm.StdinPipes[name]; exists { pipe.Close() delete(pm.StdinPipes, name) } fmt.Printf("Server instance '%s' has shut down or terminated.\n", name) } func (pm *ProcessManager) SendCommand(name string, command string) error { pm.RLock() pipe, exists := pm.StdinPipes[name] pm.RUnlock() if !exists { return fmt.Errorf("Cannot send command, instance with name '%s' is not running", name) } if command != "" && !strings.HasPrefix(command, "/") { command = "/" + command } _, err := io.WriteString(pipe, command+"\n") if err != nil { return fmt.Errorf("failed writing to server process stdin conduit: %w", err) } return nil } func NewInstanceLogBuffer(maxLines int) *InstanceLogBuffer { return &InstanceLogBuffer{ Lines: make([]string, 0, maxLines), MaxLines: maxLines, } } func (lb *InstanceLogBuffer) Append(line string) { lb.Lock() defer lb.Unlock() if len(lb.Lines) >= lb.MaxLines { // Shift array out by dropping index 0 lb.Lines = lb.Lines[1:] } lb.Lines = append(lb.Lines, line) } func (lb *InstanceLogBuffer) GetSnapshot() []string { lb.RLock() defer lb.RUnlock() // Return a copy so the caller can safely iterate or serialize to JSON without lock conflicts snapshot := make([]string, len(lb.Lines)) copy(snapshot, lb.Lines) return snapshot }