Строчные ссылки на файлы модов и своё ожидание готовности
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком, вместе с именем папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды» раскладывает рядом строчные симлинки: файлы модов не меняются, повторный запуск после докачки безопасен, а на файловых системах без различия регистра кнопка честно говорит, что делать нечего. Ожидание строки о готовности стало настройкой профиля со значением по умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати не хватает, и панель объявляла бы работающим сервер, который ещё никого не пустит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/casefix"
|
||||
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
)
|
||||
@@ -131,3 +134,40 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
|
||||
"items": s.workshop.Details(r.Context(), items),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCaseFixMods раскладывает строчные ссылки на файлы модов. Игра опускает
|
||||
// путь к вложенным файлам анимаций в нижний регистр целиком, и на Linux мод с
|
||||
// заглавными буквами в имени сыплет в лог сотни FileNotFoundException.
|
||||
//
|
||||
// Останавливать сервер не требуется: операция только добавляет имена и ничего
|
||||
// не переписывает. Подхватятся ссылки при следующем запуске — анимации
|
||||
// читаются один раз, при загрузке мира.
|
||||
func (s *Server) handleCaseFixMods(w http.ResponseWriter, r *http.Request) {
|
||||
name := "Строчные ссылки на файлы модов"
|
||||
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
|
||||
result, err := casefix.Apply(ctx, []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case result.CaseInsensitive:
|
||||
log("Файловая система не различает регистр — ссылки не нужны")
|
||||
case result.Created == 0:
|
||||
log(fmt.Sprintf("Всё уже на месте: имён просмотрено %d, ссылок было %d",
|
||||
result.Scanned, result.Existing))
|
||||
default:
|
||||
log(fmt.Sprintf("Создано ссылок: %d (было %d, имён просмотрено %d)",
|
||||
result.Created, result.Existing, result.Scanned))
|
||||
log("Перезапустите игровой сервер, чтобы он перечитал анимации")
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
log(fmt.Sprintf("Не удалось создать ссылок: %d", result.Failed))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOpError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||
)
|
||||
|
||||
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске
|
||||
// сервера: иначе про опечатку в профиле стало бы известно только из лога
|
||||
// упавшего сервера.
|
||||
func validateProfileMemory(dto profileDTO) error {
|
||||
// validateProfile проверяет числа из формы здесь, а не при запуске сервера:
|
||||
// иначе про опечатку в профиле стало бы известно только из лога упавшего
|
||||
// сервера.
|
||||
func validateProfile(dto profileDTO) error {
|
||||
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
|
||||
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
|
||||
dto.JavaMemoryMin, dto.JavaMemory)
|
||||
}
|
||||
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
|
||||
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
|
||||
maxStartupTimeoutMin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startupTimeout переводит минуты из формы в длительность. Ноль оставляем
|
||||
// нулём: он означает «как по умолчанию», и записывать в профиль конкретное
|
||||
// число значило бы заморозить его при смене умолчания.
|
||||
func startupTimeout(dto profileDTO) config.Duration {
|
||||
if dto.StartupTimeoutMin <= 0 {
|
||||
return 0
|
||||
}
|
||||
return config.Duration(time.Duration(dto.StartupTimeoutMin) * time.Minute)
|
||||
}
|
||||
|
||||
// maxStartupTimeoutMin — потолок на ожидание готовности. Больше трёх часов
|
||||
// это уже не «долго грузится», а зависший сервер.
|
||||
const maxStartupTimeoutMin = 180
|
||||
|
||||
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль
|
||||
// наружу не отдаётся, только признак того, что он задан.
|
||||
type profileDTO struct {
|
||||
@@ -39,7 +58,10 @@ type profileDTO struct {
|
||||
JavaMemoryMin string `json:"java_memory_min"`
|
||||
ExtraArgs []string `json:"extra_args"`
|
||||
HasAdminPassword bool `json:"has_admin_password"`
|
||||
AdminPassword string `json:"admin_password,omitempty"`
|
||||
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
|
||||
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
|
||||
StartupTimeoutMin int `json:"startup_timeout_min"`
|
||||
AdminPassword string `json:"admin_password,omitempty"`
|
||||
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
||||
// ещё ни разу не запускался.
|
||||
HasConfig bool `json:"has_config"`
|
||||
@@ -51,15 +73,16 @@ type profileDTO struct {
|
||||
|
||||
func (s *Server) profileToDTO(p config.Profile) profileDTO {
|
||||
return profileDTO{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
JavaMemory: p.JavaMemory,
|
||||
JavaMemoryMin: p.JavaMemoryMin,
|
||||
ExtraArgs: p.ExtraArgs,
|
||||
HasAdminPassword: p.AdminPassword != "",
|
||||
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
||||
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
||||
Active: p.ID == s.cfg.Active,
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
JavaMemory: p.JavaMemory,
|
||||
JavaMemoryMin: p.JavaMemoryMin,
|
||||
ExtraArgs: p.ExtraArgs,
|
||||
HasAdminPassword: p.AdminPassword != "",
|
||||
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
|
||||
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
||||
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
||||
Active: p.ID == s.cfg.Active,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +92,9 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
profiles = append(profiles, s.profileToDTO(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"profiles": profiles,
|
||||
"active": s.cfg.Active,
|
||||
"profiles": profiles,
|
||||
"active": s.cfg.Active,
|
||||
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
|
||||
// Переключать профиль можно только на остановленном сервере.
|
||||
"can_switch": s.sup.State() == pzserver.StateStopped,
|
||||
})
|
||||
@@ -83,18 +107,19 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &dto) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
if err := validateProfile(dto); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
profile := config.Profile{
|
||||
ID: dto.ID,
|
||||
Title: dto.Title,
|
||||
JavaMemory: dto.JavaMemory,
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
AdminPassword: dto.AdminPassword,
|
||||
ID: dto.ID,
|
||||
Title: dto.Title,
|
||||
JavaMemory: dto.JavaMemory,
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
AdminPassword: dto.AdminPassword,
|
||||
StartupTimeout: startupTimeout(dto),
|
||||
}
|
||||
if err := s.cfg.AddProfile(profile); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
@@ -160,17 +185,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &dto) {
|
||||
return
|
||||
}
|
||||
if err := validateProfileMemory(dto); err != nil {
|
||||
if err := validateProfile(dto); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updated := config.Profile{
|
||||
ID: id,
|
||||
Title: dto.Title,
|
||||
JavaMemory: dto.JavaMemory,
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
ID: id,
|
||||
Title: dto.Title,
|
||||
JavaMemory: dto.JavaMemory,
|
||||
JavaMemoryMin: dto.JavaMemoryMin,
|
||||
ExtraArgs: dto.ExtraArgs,
|
||||
StartupTimeout: startupTimeout(dto),
|
||||
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
||||
// и форма присылает пустую строку, пока его не меняли.
|
||||
AdminPassword: current.AdminPassword,
|
||||
|
||||
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
|
||||
"PUT /api/mods": s.handlePutMods,
|
||||
"POST /api/mods/lookup": s.handleLookupMods,
|
||||
"POST /api/mods/collection": s.handleExpandCollection,
|
||||
"POST /api/mods/casefix": s.handleCaseFixMods,
|
||||
"GET /api/backups": s.handleListBackups,
|
||||
"POST /api/backups": s.handleCreateBackup,
|
||||
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package casefix лечит расхождение в регистре имён у модов мастерской.
|
||||
//
|
||||
// Project Zomboid приводит путь к вложенному файлу анимаций к нижнему регистру
|
||||
// целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
|
||||
// файл просто не находится, и сервер сыплет в лог сотнями исключений вида
|
||||
//
|
||||
// AnimNode.Parse> ... loadshotgun_hb.xml (No such file or directory)
|
||||
//
|
||||
// при том, что рядом лежит LoadShotgun_HB.xml. Переименование не помогает:
|
||||
// ссылки внутри модов написаны в разном регистре, и в нижний уходит весь путь.
|
||||
// Поэтому рядом с каждым именем, где есть заглавные буквы, кладём симлинк на
|
||||
// него же строчными буквами. Файлы модов при этом не трогаются: операция
|
||||
// только добавляет имена и никогда не переписывает существующие.
|
||||
package casefix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pzAppID — раздел мастерской, в который Steam кладёт моды Project Zomboid.
|
||||
const pzAppID = "108600"
|
||||
|
||||
// maxDepth ограничивает обход: дерево мода мельче, а бесконечно глубокое
|
||||
// означает, что мы куда-то не туда попали.
|
||||
const maxDepth = 24
|
||||
|
||||
// logSamples — сколько первых ссылок показать в консоли. Их бывают тысячи, и
|
||||
// сплошной список вытеснит из буфера всё остальное.
|
||||
const logSamples = 20
|
||||
|
||||
// Result — что получилось. Existing считает уже готовые ссылки: повторный
|
||||
// запуск после докачки модов должен быть дешёвым и безопасным.
|
||||
type Result struct {
|
||||
Roots []string `json:"roots"`
|
||||
Scanned int `json:"scanned"`
|
||||
Created int `json:"created"`
|
||||
Existing int `json:"existing"`
|
||||
Failed int `json:"failed"`
|
||||
// CaseInsensitive — файловая система сама не различает регистр, и ссылки
|
||||
// не нужны. Так ведут себя Windows и macOS: там этой беды просто нет.
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
}
|
||||
|
||||
// Apply обходит папки мастерской внутри roots и раскладывает строчные ссылки.
|
||||
func Apply(ctx context.Context, roots []string, log func(string)) (Result, error) {
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
}
|
||||
var result Result
|
||||
var firstErr error
|
||||
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
|
||||
if info, err := os.Stat(content); err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
result.Roots = append(result.Roots, content)
|
||||
// Проверяем до обхода: на нечувствительной к регистру ФС каждое имя
|
||||
// «уже существует», и обход насчитал бы тысячи готовых ссылок там, где
|
||||
// их нет вовсе.
|
||||
if caseInsensitive(content) {
|
||||
result.CaseInsensitive = true
|
||||
log(content + ": файловая система не различает регистр, ссылки не нужны")
|
||||
continue
|
||||
}
|
||||
log("Просматриваю " + content)
|
||||
if err := walk(ctx, content, 0, &result, log, &firstErr); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Roots) == 0 {
|
||||
return result, fmt.Errorf("папка модов мастерской не найдена: сервер ещё не скачивал моды")
|
||||
}
|
||||
if result.CaseInsensitive && result.Scanned == 0 {
|
||||
return result, nil
|
||||
}
|
||||
// Ни одной ссылки не создано, зато все попытки провалились — значит дело
|
||||
// не в отдельном файле, а в системе: нет прав или ФС не умеет симлинки.
|
||||
if result.Created == 0 && result.Failed > 0 && firstErr != nil {
|
||||
return result, fmt.Errorf("не удалось создать ни одной ссылки: %w", firstErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// caseInsensitive выясняет опытом, различает ли файловая система регистр:
|
||||
// кладёт временный файл с заглавными буквами и ищет его строчными. Пробовать
|
||||
// надо именно в папке модов — она может лежать на отдельном разделе.
|
||||
func caseInsensitive(dir string) bool {
|
||||
probe, err := os.CreateTemp(dir, "PZMcase*.tmp")
|
||||
if err != nil {
|
||||
// Права на запись всё равно нужны для ссылок: пусть обход сам
|
||||
// расскажет, чего именно не хватает.
|
||||
return false
|
||||
}
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
defer func() { _ = os.Remove(name) }()
|
||||
|
||||
_, err = os.Lstat(filepath.Join(filepath.Dir(name), strings.ToLower(filepath.Base(name))))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// walk обходит одну директорию. По симлинкам не спускаемся: и чтобы не ходить
|
||||
// по только что созданным ссылкам, и чтобы не зациклиться на чужих.
|
||||
func walk(ctx context.Context, dir string, depth int, result *Result, log func(string), firstErr *error) error {
|
||||
if depth > maxDepth {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// Нечитаемая папка — повод сказать об этом, но не бросать весь обход.
|
||||
log("Пропускаю " + dir + ": " + err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
result.Scanned++
|
||||
name := entry.Name()
|
||||
lower := strings.ToLower(name)
|
||||
if lower != name {
|
||||
link(dir, name, lower, result, log, firstErr)
|
||||
}
|
||||
// entry.IsDir() у симлинка на папку даёт false: ReadDir не разыменовывает.
|
||||
if entry.IsDir() {
|
||||
if err := walk(ctx, filepath.Join(dir, name), depth+1, result, log, firstErr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// link кладёт рядом с name ссылку на него же строчными буквами.
|
||||
func link(dir, name, lower string, result *Result, log func(string), firstErr *error) {
|
||||
path := filepath.Join(dir, lower)
|
||||
// Lstat, а не Stat: битая ссылка тоже занимает имя, и создавать поверх неё
|
||||
// нечего.
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
result.Existing++
|
||||
return
|
||||
}
|
||||
// Цель относительная — ссылка переживёт переезд папки сервера.
|
||||
if err := os.Symlink(name, path); err != nil {
|
||||
result.Failed++
|
||||
if *firstErr == nil {
|
||||
*firstErr = err
|
||||
}
|
||||
if result.Failed <= logSamples {
|
||||
log("Не удалось создать ссылку " + path + ": " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
result.Created++
|
||||
if result.Created <= logSamples {
|
||||
log(lower + " → " + name)
|
||||
}
|
||||
if result.Created == logSamples+1 {
|
||||
log("…дальше молча, итог будет в конце")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package casefix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// content собирает дерево мастерской с файлами в разном регистре.
|
||||
func content(t *testing.T) (root, dir string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
dir = filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
|
||||
"3722134990", "mods", "GunsOfMarz", "42.16", "media", "AnimSets")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"LoadShotgun_HB.xml", "aim_down.xml"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("<anim/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root, dir
|
||||
}
|
||||
|
||||
// Проверять создание ссылок есть смысл только там, где регистр вообще
|
||||
// различается: на Windows и macOS чинить нечего.
|
||||
func skipCaseInsensitive(t *testing.T, result Result) {
|
||||
t.Helper()
|
||||
if result.CaseInsensitive {
|
||||
t.Skip("файловая система не различает регистр")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLinksUppercaseNames(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
|
||||
result, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skipCaseInsensitive(t, result)
|
||||
if result.Created == 0 {
|
||||
t.Fatalf("не создано ни одной ссылки: %+v", result)
|
||||
}
|
||||
|
||||
// Ссылка нужна и на файл, и на каждую папку пути: игра опускает в нижний
|
||||
// регистр путь целиком, включая имя мода.
|
||||
link := filepath.Join(dir, "loadshotgun_hb.xml")
|
||||
if data, err := os.ReadFile(link); err != nil || string(data) != "<anim/>" {
|
||||
t.Fatalf("ссылка на файл не читается: %v", err)
|
||||
}
|
||||
modDir := filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
|
||||
"3722134990", "mods", "gunsofmarz")
|
||||
if _, err := os.Stat(modDir); err != nil {
|
||||
t.Fatalf("нет ссылки на папку мода: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(modDir, "42.16", "media", "animsets", "loadshotgun_hb.xml")); err != nil {
|
||||
t.Fatalf("путь целиком строчными не собрался: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Повторный запуск после докачки модов не должен ничего ломать.
|
||||
func TestApplyIsIdempotent(t *testing.T) {
|
||||
root, _ := content(t)
|
||||
|
||||
first, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skipCaseInsensitive(t, first)
|
||||
second, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Created != 0 {
|
||||
t.Errorf("второй проход создал ссылки заново: %d", second.Created)
|
||||
}
|
||||
if second.Existing != first.Created {
|
||||
t.Errorf("готовых ссылок %d, а создано было %d", second.Existing, first.Created)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя, которое уже занято настоящим файлом, не трогаем.
|
||||
func TestApplyKeepsExistingLowercaseFile(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
path := filepath.Join(dir, "loadshotgun_hb.xml")
|
||||
if err := os.WriteFile(path, []byte("свой файл"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := Apply(context.Background(), []string{root}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "свой файл" {
|
||||
t.Fatalf("существующий файл перезаписан: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithoutWorkshopDir(t *testing.T) {
|
||||
if _, err := Apply(context.Background(), []string{t.TempDir()}, nil); err == nil {
|
||||
t.Fatal("ожидалась ошибка про ненайденную папку модов")
|
||||
}
|
||||
}
|
||||
|
||||
// На Windows и macOS кнопка должна честно сказать, что чинить нечего, а не
|
||||
// пересчитывать все имена как «уже готовые».
|
||||
func TestApplyReportsCaseInsensitiveFilesystem(t *testing.T) {
|
||||
root, dir := content(t)
|
||||
if !caseInsensitive(dir) {
|
||||
t.Skip("файловая система различает регистр")
|
||||
}
|
||||
|
||||
result, err := Apply(context.Background(), []string{root}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.CaseInsensitive {
|
||||
t.Error("нечувствительность к регистру не распознана")
|
||||
}
|
||||
if result.Created != 0 || result.Existing != 0 || result.Scanned != 0 {
|
||||
t.Errorf("дерево обошли впустую: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,27 @@ type Profile struct {
|
||||
|
||||
// ExtraArgs — дополнительные аргументы start-server.sh.
|
||||
ExtraArgs []string `yaml:"extra_args"`
|
||||
|
||||
// StartupTimeout — сколько ждать строку о готовности сервера, прежде чем
|
||||
// считать его работающим. Сборка на три сотни модов грузит мир десятками
|
||||
// минут, и общего значения на все профили не подобрать: слишком малое
|
||||
// объявит рабочим сервер, который ещё никого не пустит. Ноль означает
|
||||
// DefaultStartupTimeout.
|
||||
StartupTimeout Duration `yaml:"startup_timeout"`
|
||||
}
|
||||
|
||||
// DefaultStartupTimeout — запас на загрузку мира, когда профиль своего не
|
||||
// задал. Ванильный сервер поднимается за минуту, сборка с сотнями модов —
|
||||
// заметно дольше, поэтому запас с большим запасом.
|
||||
const DefaultStartupTimeout = 30 * time.Minute
|
||||
|
||||
// StartupWait возвращает время ожидания готовности с учётом значения по
|
||||
// умолчанию.
|
||||
func (p Profile) StartupWait() time.Duration {
|
||||
if p.StartupTimeout <= 0 {
|
||||
return DefaultStartupTimeout
|
||||
}
|
||||
return p.StartupTimeout.Duration()
|
||||
}
|
||||
|
||||
// Name возвращает название для интерфейса: заголовок, а если его нет — ID.
|
||||
|
||||
@@ -42,10 +42,8 @@ var (
|
||||
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
||||
const playersPollInterval = 30 * time.Second
|
||||
|
||||
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
|
||||
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
|
||||
// работающим хуже, чем подождать.
|
||||
const startupGrace = 15 * time.Minute
|
||||
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
|
||||
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
|
||||
|
||||
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
||||
const restartDelay = 10 * time.Second
|
||||
@@ -558,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
|
||||
// вечное «Запускается» лишило бы панель опроса игроков.
|
||||
func (s *Supervisor) checkStartupGrace() {
|
||||
s.mu.Lock()
|
||||
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
|
||||
// активный профиль могли переключить, а мир грузится по старому.
|
||||
wait := s.profile.StartupWait()
|
||||
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
||||
time.Since(s.startedAt) > startupGrace
|
||||
time.Since(s.startedAt) > wait
|
||||
if overdue {
|
||||
s.state = StateRunning
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if overdue {
|
||||
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
|
||||
s.AppendLog("manager", fmt.Sprintf(
|
||||
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user