Files
pzmanager/internal/pzserver/supervisor.go
T
mrleo1nidandClaude Opus 5 5d9bedaad2
CI / test (push) Successful in 24s
Release / release (push) Successful in 28s
Строчные ссылки на файлы модов и своё ожидание готовности
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

Ожидание строки о готовности стало настройкой профиля со значением по
умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати
не хватает, и панель объявляла бы работающим сервер, который ещё никого
не пустит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:30:40 +03:00

599 lines
21 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"
// StateRestarting — сервер упал и ждёт автоподъёма. Отдельное состояние
// нужно, чтобы это ожидание можно было отменить кнопкой.
StateRestarting State = "restarting"
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
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
// restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second
// Сервер, падающий сразу после старта, автоперезапуском не лечится: обычно
// это нехватка памяти или испорченный конфиг. После нескольких таких падений
// подряд перестаём поднимать его сам, чтобы не крутить бесконечный цикл.
const (
quickCrashWindow = time.Minute
maxQuickCrashes = 3
)
// 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"`
// RestartInSec — сколько секунд осталось до автоподъёма (0, если не ждём).
RestartInSec int64 `json:"restart_in_sec"`
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
// restartCancel закрывается, чтобы отменить ожидание автоперезапуска;
// restartAt — когда оно закончится.
restartCancel chan struct{}
restartAt time.Time
// crashStreak — сколько раз подряд сервер упал сразу после запуска.
crashStreak int
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) }
// LogEpoch — метка этого запуска менеджера. Меняется вместе с нумерацией строк.
func (s *Supervisor) LogEpoch() string { return s.log.epoch }
// 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 && s.state != StateRestarting {
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
}
if s.state == StateRestarting && !s.restartAt.IsZero() {
if left := time.Until(s.restartAt).Seconds(); left > 0 {
st.RestartInSec = int64(left + 0.5)
}
}
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()
// Запуск руками — знак, что причину падений устранили.
s.crashStreak = 0
// Если ждали автоподъёма, поднимаем немедленно.
s.cancelRestartLocked()
return s.startLocked()
}
// cancelRestartLocked снимает запланированный автоперезапуск. Вызывается под
// удержанным s.mu.
func (s *Supervisor) cancelRestartLocked() {
if s.restartCancel == nil {
return
}
close(s.restartCancel)
s.restartCancel = nil
s.restartAt = time.Time{}
if s.state == StateRestarting {
s.state = StateStopped
}
}
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, profile.JavaMemoryMin); 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 сообщает о готовности принимать
// игроков: «LOG : Network f:0 st:81,093,763> *** SERVER STARTED ****».
//
// Других маркеров тут нет намеренно. Регистрацию в Steam сервер печатает
// задолго до того, как мир загружен, и по ней панель показывала «Работает» на
// сервере, который ещё никого не пустит.
func isServerReady(line string) bool {
return strings.Contains(strings.ToUpper(line), "SERVER STARTED")
}
// wait дожидается завершения процесса и решает, поднимать ли его заново.
func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
err := cmd.Wait()
close(exited)
lifetime := time.Since(s.startedAt)
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
// Падение сразу после запуска — признак того, что перезапуск не поможет:
// не хватило памяти, испорчен конфиг, нет файлов сервера.
if !wasIntentional && lifetime < quickCrashWindow {
s.crashStreak++
} else {
s.crashStreak = 0
}
streak := s.crashStreak
autoRestart := s.cfg.AutoRestart && !wasIntentional
s.mu.Unlock()
s.AppendLog("manager", "Сервер остановлен: "+reason)
if !autoRestart {
return
}
if streak >= maxQuickCrashes {
s.AppendLog("manager", fmt.Sprintf(
"Сервер падает сразу после запуска %d раза подряд — автоперезапуск остановлен. "+
"Посмотрите ошибку выше и запустите сервер вручную.", streak))
return
}
s.mu.Lock()
cancel := make(chan struct{})
s.restartCancel = cancel
s.restartAt = time.Now().Add(restartDelay)
s.state = StateRestarting
s.mu.Unlock()
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s (можно отменить кнопкой «Остановить»)", restartDelay))
select {
case <-time.After(restartDelay):
case <-cancel:
// Состояние уже переставил тот, кто отменил ожидание.
s.AppendLog("manager", "Автоперезапуск отменён")
return
case <-s.quit:
return
}
s.mu.Lock()
s.restartCancel = nil
s.restartAt = time.Time{}
if s.state == StateRestarting {
s.state = StateStopped
}
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 == StateRestarting {
s.cancelRestartLocked()
s.mu.Unlock()
return nil
}
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
}
// checkStartupGrace страхует от сервера, который так и не напечатал строку о
// готовности: со своими модами и своей версией он может назвать её иначе, а
// вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() {
s.mu.Lock()
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
// активный профиль могли переключить, а мир грузится по старому.
wait := s.profile.StartupWait()
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > wait
if overdue {
s.state = StateRunning
}
s.mu.Unlock()
if overdue {
s.AppendLog("manager", fmt.Sprintf(
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
}
}
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
func (s *Supervisor) pollPlayers() {
ticker := time.NewTicker(playersPollInterval)
defer ticker.Stop()
for {
select {
case <-s.quit:
return
case <-ticker.C:
s.checkStartupGrace()
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()
}
}
}
}