Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package pzserver
|
||||
|
||||
import "sync"
|
||||
|
||||
// LogLine — одна строка консоли сервера с монотонным номером, по которому
|
||||
// веб-клиент понимает, что он пропустил, и откуда догружать.
|
||||
type LogLine struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Time int64 `json:"time"` // Unix-миллисекунды
|
||||
Stream string `json:"stream"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// logBuffer — кольцевой буфер последних строк консоли плюс рассылка их
|
||||
// живым подписчикам (SSE-соединениям веб-панели).
|
||||
type logBuffer struct {
|
||||
mu sync.RWMutex
|
||||
lines []LogLine
|
||||
capacity int
|
||||
nextSeq int64
|
||||
subs map[int]chan LogLine
|
||||
nextSub int
|
||||
}
|
||||
|
||||
func newLogBuffer(capacity int) *logBuffer {
|
||||
return &logBuffer{
|
||||
lines: make([]LogLine, 0, capacity),
|
||||
capacity: capacity,
|
||||
nextSeq: 1,
|
||||
subs: make(map[int]chan LogLine),
|
||||
}
|
||||
}
|
||||
|
||||
// append кладёт строку в буфер и рассылает подписчикам.
|
||||
func (b *logBuffer) append(line LogLine) {
|
||||
b.mu.Lock()
|
||||
line.Seq = b.nextSeq
|
||||
b.nextSeq++
|
||||
if len(b.lines) == b.capacity {
|
||||
copy(b.lines, b.lines[1:])
|
||||
b.lines = b.lines[:len(b.lines)-1]
|
||||
}
|
||||
b.lines = append(b.lines, line)
|
||||
|
||||
subs := make([]chan LogLine, 0, len(b.subs))
|
||||
for _, ch := range b.subs {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, ch := range subs {
|
||||
// Неблокирующая отправка: подписчик, который не успевает читать,
|
||||
// теряет строки, но не тормозит вывод сервера.
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// since возвращает строки с номером больше seq (seq<=0 — весь буфер).
|
||||
func (b *logBuffer) since(seq int64) []LogLine {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]LogLine, 0, len(b.lines))
|
||||
for _, l := range b.lines {
|
||||
if l.Seq > seq {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subscribe открывает канал живых строк и функцию отписки.
|
||||
func (b *logBuffer) subscribe(bufferSize int) (<-chan LogLine, func()) {
|
||||
ch := make(chan LogLine, bufferSize)
|
||||
b.mu.Lock()
|
||||
id := b.nextSub
|
||||
b.nextSub++
|
||||
b.subs[id] = ch
|
||||
b.mu.Unlock()
|
||||
|
||||
return ch, func() {
|
||||
b.mu.Lock()
|
||||
if existing, ok := b.subs[id]; ok {
|
||||
delete(b.subs, id)
|
||||
close(existing)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package pzserver
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setProcessGroup просит ядро выделить процессу собственную группу: сервер PZ
|
||||
// это скрипт-обёртка, порождающая JVM, и убивать их нужно вместе.
|
||||
func setProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// processGroupID возвращает pgid запущенного процесса.
|
||||
func processGroupID(pid int) int {
|
||||
pgid, err := syscall.Getpgid(pid)
|
||||
if err != nil {
|
||||
return pid
|
||||
}
|
||||
return pgid
|
||||
}
|
||||
|
||||
// signalGroup шлёт сигнал всей группе (отрицательный pid = группа).
|
||||
func signalGroup(pgid int, sig syscall.Signal) error {
|
||||
return syscall.Kill(-pgid, sig)
|
||||
}
|
||||
|
||||
var (
|
||||
sigTerm = syscall.SIGTERM
|
||||
sigKill = syscall.SIGKILL
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build windows
|
||||
|
||||
package pzserver
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// На Windows групп процессов в POSIX-смысле нет. Менеджер рассчитан на
|
||||
// Ubuntu; эти заглушки нужны лишь для сборки и отладки под Windows.
|
||||
func setProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
func processGroupID(pid int) int { return pid }
|
||||
|
||||
func signalGroup(pgid int, sig syscall.Signal) error {
|
||||
p, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, uint32(pgid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer syscall.CloseHandle(p)
|
||||
return syscall.TerminateProcess(p, 1)
|
||||
}
|
||||
|
||||
var (
|
||||
sigTerm = syscall.Signal(15)
|
||||
sigKill = syscall.Signal(9)
|
||||
)
|
||||
@@ -0,0 +1,459 @@
|
||||
// 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/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"`
|
||||
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
|
||||
// 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 {
|
||||
s.mu.Lock()
|
||||
st := Status{
|
||||
State: s.state,
|
||||
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)
|
||||
}
|
||||
|
||||
args := []string{"-servername", s.cfg.ServerName}
|
||||
if s.cfg.AdminPassword != "" {
|
||||
args = append(args, "-adminpassword", s.cfg.AdminPassword)
|
||||
}
|
||||
args = append(args, s.cfg.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.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("Запуск сервера: %s %s (pid %d)",
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user