Files
pzmanager/internal/pzserver/supervisor.go
T
mrleo1nidandClaude Opus 5 277ab7af4a Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

Удаление профиля убирает его только из панели: мир и конфиги остаются на
диске, чтобы случайное нажатие не стоило сохранений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:27:12 +03:00

483 lines
16 KiB
Go

// Package pzserver запускает игровой сервер Project Zomboid как дочерний
// процесс, держит его консоль и следит за его состоянием.
package pzserver
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
)
// State — фаза жизненного цикла игрового сервера.
type State string
const (
StateStopped State = "stopped"
StateStarting State = "starting"
StateRunning State = "running"
StateStopping State = "stopping"
)
// Ошибки, на которые HTTP-слой отвечает осмысленным кодом.
var (
ErrAlreadyRunning = errors.New("сервер уже запущен")
ErrNotRunning = errors.New("сервер не запущен")
ErrNotInstalled = errors.New("серверные файлы не найдены — сначала выполните установку")
)
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second
// restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second
// Status — снимок состояния для веб-панели.
type Status struct {
State State `json:"state"`
// Profile и ProfileTitle описывают профиль, который сейчас работает
// (а если сервер остановлен — который будет запущен).
Profile string `json:"profile"`
ProfileTitle string `json:"profile_title"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
}
// Supervisor владеет процессом сервера: запускает, кормит командами консоли,
// собирает вывод и по желанию поднимает после падения.
type Supervisor struct {
cfg *config.Config
log *logBuffer
mu sync.Mutex
state State
cmd *exec.Cmd
stdin io.WriteCloser
pgid int
startedAt time.Time
lastExit string
players []string
playersAt time.Time
// profile — профиль, с которым сервер был запущен: активный профиль
// в конфиге может отличаться, если его успели переключить.
profile config.Profile
// intentionalStop отличает остановку по кнопке от падения: во втором
// случае включается автоперезапуск.
intentionalStop bool
exited chan struct{}
// Захват ответа на команду players: сервер печатает заголовок и список
// строк вида -Nick.
capturing bool
captured []string
autoQuery bool
sampler *sysinfo.Sampler
stopOnce sync.Once
quit chan struct{}
}
// New создаёт супервизор поверх конфига. Конфиг читается при каждом запуске,
// поэтому изменения настроек подхватываются следующим стартом сервера.
func New(cfg *config.Config) *Supervisor {
s := &Supervisor{
cfg: cfg,
log: newLogBuffer(cfg.LogBufferLines),
state: StateStopped,
sampler: sysinfo.NewSampler(),
quit: make(chan struct{}),
}
go s.pollPlayers()
return s
}
// Close останавливает фоновые задачи супервизора.
func (s *Supervisor) Close() {
s.stopOnce.Do(func() { close(s.quit) })
}
// Installed сообщает, лежат ли на диске файлы сервера.
func (s *Supervisor) Installed() bool {
_, err := os.Stat(s.startScript())
return err == nil
}
func (s *Supervisor) startScript() string {
return filepath.Join(s.cfg.ServerDir, "start-server.sh")
}
// AppendLog добавляет строку в консоль панели — им пользуются задачи
// установки и бэкапа, чтобы их вывод был виден там же, где логи сервера.
func (s *Supervisor) AppendLog(stream, text string) {
s.log.append(LogLine{Time: time.Now().UnixMilli(), Stream: stream, Text: text})
}
// Lines возвращает историю консоли начиная с номера seq.
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
// Subscribe отдаёт канал новых строк консоли и функцию отписки.
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
// State возвращает текущую фазу без остальных метрик.
func (s *Supervisor) State() State {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
// Status собирает полный снимок состояния вместе с метриками процесса и хоста.
func (s *Supervisor) Status() Status {
profile := s.cfg.ActiveProfile()
s.mu.Lock()
if s.state != StateStopped {
profile = s.profile
}
st := Status{
State: s.state,
Profile: profile.ID,
ProfileTitle: profile.Name(),
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
}
if !s.playersAt.IsZero() {
st.PlayersAt = s.playersAt.Unix()
}
pgid := s.pgid
if s.cmd != nil && s.cmd.Process != nil {
st.PID = s.cmd.Process.Pid
}
if !s.startedAt.IsZero() && s.state != StateStopped {
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
}
s.mu.Unlock()
st.Installed = s.Installed()
if st.State == StateRunning || st.State == StateStarting {
st.Proc = s.sampler.Sample(pgid)
} else {
s.sampler.Reset()
}
st.Host = sysinfo.Host(s.cfg.ZomboidDir)
return st
}
// Start поднимает игровой сервер. Возвращает ошибку, если он уже работает
// или файлы сервера ещё не установлены.
func (s *Supervisor) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.startLocked()
}
func (s *Supervisor) startLocked() error {
if s.state != StateStopped {
return ErrAlreadyRunning
}
script := s.startScript()
if _, err := os.Stat(script); err != nil {
return ErrNotInstalled
}
// Права на исполнение теряются, например, после распаковки из архива.
if err := os.Chmod(script, 0o755); err != nil {
return fmt.Errorf("chmod %s: %w", script, err)
}
profile := s.cfg.ActiveProfile()
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error())
}
args := []string{"-servername", profile.ID}
if profile.AdminPassword != "" {
args = append(args, "-adminpassword", profile.AdminPassword)
}
args = append(args, profile.ExtraArgs...)
cmd := exec.Command(script, args...)
cmd.Dir = s.cfg.ServerDir
// PZ ищет сохранения относительно HOME, а нативные библиотеки — рядом с
// собой; обе переменные задаём явно, чтобы не зависеть от окружения
// systemd-юнита.
cmd.Env = append(os.Environ(),
"HOME="+filepath.Dir(s.cfg.ZomboidDir),
"LD_LIBRARY_PATH="+s.cfg.ServerDir+"/linux64:"+s.cfg.ServerDir+"/natives:"+os.Getenv("LD_LIBRARY_PATH"),
)
setProcessGroup(cmd)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("запустить %s: %w", script, err)
}
s.cmd = cmd
s.profile = profile
s.stdin = stdin
s.pgid = processGroupID(cmd.Process.Pid)
s.state = StateStarting
s.startedAt = time.Now()
s.lastExit = ""
s.intentionalStop = false
s.players = nil
s.exited = make(chan struct{})
s.sampler.Reset()
s.AppendLog("manager", fmt.Sprintf("Запуск профиля %q: %s %s (pid %d)",
profile.Name(), script, strings.Join(args, " "), cmd.Process.Pid))
go s.readPipe(stdout, "stdout")
go s.readPipe(stderr, "stderr")
go s.wait(cmd, s.exited)
return nil
}
// readPipe построчно читает вывод сервера. Буфер увеличен: PZ иногда печатает
// длинные стек-трейсы одной строкой.
func (s *Supervisor) readPipe(r io.Reader, stream string) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
s.handleLine(stream, scanner.Text())
}
}
// handleLine разбирает строку вывода: ловит момент готовности сервера и
// ответы на команду players, остальное складывает в консоль.
func (s *Supervisor) handleLine(stream, text string) {
trimmed := strings.TrimRight(text, "\r")
s.mu.Lock()
hide := false
switch {
case s.capturing:
if stripped := strings.TrimSpace(trimmed); strings.HasPrefix(stripped, "-") {
s.captured = append(s.captured, strings.TrimPrefix(stripped, "-"))
s.players = append([]string{}, s.captured...)
hide = s.autoQuery
} else {
s.capturing = false
s.autoQuery = false
}
case strings.Contains(trimmed, "Players connected"):
s.capturing = true
s.captured = nil
s.players = nil
s.playersAt = time.Now()
hide = s.autoQuery
}
if s.state == StateStarting && isServerReady(trimmed) {
s.state = StateRunning
}
s.mu.Unlock()
// Автоопрос игроков не должен засорять консоль — команду отправили мы,
// а не пользователь.
if !hide {
s.AppendLog(stream, trimmed)
}
}
// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать
// игроков.
func isServerReady(line string) bool {
return strings.Contains(line, "SERVER STARTED") ||
strings.Contains(line, "Server Steam ID")
}
// wait дожидается завершения процесса и решает, поднимать ли его заново.
func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
err := cmd.Wait()
close(exited)
s.mu.Lock()
wasIntentional := s.intentionalStop
s.state = StateStopped
s.cmd = nil
s.stdin = nil
s.pgid = 0
s.players = nil
s.capturing = false
if err != nil {
s.lastExit = err.Error()
} else {
s.lastExit = "штатное завершение"
}
reason := s.lastExit
autoRestart := s.cfg.AutoRestart && !wasIntentional
s.mu.Unlock()
s.AppendLog("manager", "Сервер остановлен: "+reason)
if !autoRestart {
return
}
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s", restartDelay))
select {
case <-time.After(restartDelay):
case <-s.quit:
return
}
s.mu.Lock()
err = s.startLocked()
s.mu.Unlock()
if err != nil && !errors.Is(err, ErrAlreadyRunning) {
s.AppendLog("manager", "Автоперезапуск не удался: "+err.Error())
}
}
// Send отправляет команду в консоль сервера.
func (s *Supervisor) Send(command string) error {
command = strings.TrimSpace(command)
if command == "" {
return errors.New("пустая команда")
}
s.mu.Lock()
stdin, state := s.stdin, s.state
s.mu.Unlock()
if stdin == nil || state == StateStopped {
return ErrNotRunning
}
s.AppendLog("console", "> "+command)
if _, err := io.WriteString(stdin, command+"\n"); err != nil {
return fmt.Errorf("запись в консоль: %w", err)
}
return nil
}
// sendQuiet отправляет служебную команду, не показывая её в консоли панели.
func (s *Supervisor) sendQuiet(command string) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return ErrNotRunning
}
_, err := io.WriteString(stdin, command+"\n")
return err
}
// Stop корректно останавливает сервер: сначала команда quit (она сохраняет
// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL.
func (s *Supervisor) Stop() error {
s.mu.Lock()
if s.state == StateStopped || s.cmd == nil {
s.mu.Unlock()
return ErrNotRunning
}
s.state = StateStopping
s.intentionalStop = true
exited, pgid := s.exited, s.pgid
s.mu.Unlock()
s.AppendLog("manager", "Останавливаю сервер командой quit...")
if err := s.sendQuiet("quit"); err != nil {
s.AppendLog("manager", "Не удалось отправить quit: "+err.Error())
}
select {
case <-exited:
return nil
case <-time.After(s.cfg.StopTimeout.Duration()):
}
s.AppendLog("manager", "Сервер не ответил на quit — отправляю SIGTERM")
if err := signalGroup(pgid, sigTerm); err != nil {
s.AppendLog("manager", "SIGTERM не доставлен: "+err.Error())
}
select {
case <-exited:
return nil
case <-time.After(30 * time.Second):
}
s.AppendLog("manager", "Сервер завис — отправляю SIGKILL")
if err := signalGroup(pgid, sigKill); err != nil {
return fmt.Errorf("SIGKILL: %w", err)
}
<-exited
return nil
}
// Restart останавливает сервер и поднимает его заново.
func (s *Supervisor) Restart() error {
if s.State() != StateStopped {
if err := s.Stop(); err != nil && !errors.Is(err, ErrNotRunning) {
return err
}
// После Stop горутина wait ещё должна снять состояние.
s.WaitStopped(15 * time.Second)
}
return s.Start()
}
// WaitStopped ждёт, пока состояние станет stopped, но не дольше timeout.
// Возвращает true, если сервер действительно остановился.
func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if s.State() == StateStopped {
return true
}
time.Sleep(100 * time.Millisecond)
}
return s.State() == StateStopped
}
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
func (s *Supervisor) pollPlayers() {
ticker := time.NewTicker(playersPollInterval)
defer ticker.Stop()
for {
select {
case <-s.quit:
return
case <-ticker.C:
if s.State() != StateRunning {
continue
}
s.mu.Lock()
s.autoQuery = true
s.mu.Unlock()
if err := s.sendQuiet("players"); err != nil {
s.mu.Lock()
s.autoQuery = false
s.mu.Unlock()
}
}
}
}