From e78a72d51688367fd58ff460bb80294908c0ca49 Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Sat, 5 Sep 2026 02:24:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20JVM=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE,=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель принудительно ставила -Xms равным -Xmx: куча не растёт на ходу, но JVM обязана занять весь объём при старте. На машине с 5 ГБ ОЗУ сервер с 4g так вообще не поднимался — «Failed to allocate initial Java heap». Теперь -Xmx задаётся как раньше, а -Xms стал отдельным полем профиля и по умолчанию пуст: JVM берёт память по мере надобности. Пара значений проверяется при сохранении профиля, а не при запуске сервера, — про опечатку лучше узнать из формы, чем из лога упавшего сервера. Там же в супервизоре: ожидание автоподъёма стало отдельным состоянием. Раньше упавший сервер уходил в цикл «падение — пауза 10 секунд — падение», причём во время паузы он считался остановленным, и прервать это было нечем. Теперь паузу видно в статусе с обратным отсчётом и её можно отменить, а после трёх падений сразу после запуска подряд панель перестаёт поднимать сервер сама: так проявляется нехватка памяти или битый конфиг, и перезапуск тут не помогает. Co-Authored-By: Claude Opus 5 --- internal/api/handlers_profiles.go | 40 ++++++- internal/api/handlers_tasks.go | 10 +- internal/config/config.go | 5 + internal/pzconfig/java.go | 76 +++++++++++--- internal/pzconfig/java_test.go | 167 ++++++++++++++++++++++++++++++ internal/pzserver/supervisor.go | 119 ++++++++++++++++++--- 6 files changed, 380 insertions(+), 37 deletions(-) create mode 100644 internal/pzconfig/java_test.go diff --git a/internal/api/handlers_profiles.go b/internal/api/handlers_profiles.go index 50576ef..5877462 100644 --- a/internal/api/handlers_profiles.go +++ b/internal/api/handlers_profiles.go @@ -7,15 +7,36 @@ import ( "os" "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 { + maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory) + if err != nil { + return err + } + minBytes, err := pzconfig.ParseMemory(dto.JavaMemoryMin) + if err != nil { + return err + } + if maxBytes > 0 && minBytes > maxBytes { + return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)", + dto.JavaMemoryMin, dto.JavaMemory) + } + return nil +} + // profileDTO — профиль в том виде, в каком его показывает панель. Пароль // наружу не отдаётся, только признак того, что он задан. type profileDTO struct { ID string `json:"id"` Title string `json:"title"` JavaMemory string `json:"java_memory"` + JavaMemoryMin string `json:"java_memory_min"` ExtraArgs []string `json:"extra_args"` HasAdminPassword bool `json:"has_admin_password"` AdminPassword string `json:"admin_password,omitempty"` @@ -33,6 +54,7 @@ func (s *Server) profileToDTO(p config.Profile) 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)), @@ -61,11 +83,16 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &dto) { return } + if err := validateProfileMemory(dto); err != nil { + writeError(w, 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, } @@ -133,12 +160,17 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &dto) { return } + if err := validateProfileMemory(dto); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } updated := config.Profile{ - ID: id, - Title: dto.Title, - JavaMemory: dto.JavaMemory, - ExtraArgs: dto.ExtraArgs, + ID: id, + Title: dto.Title, + JavaMemory: dto.JavaMemory, + JavaMemoryMin: dto.JavaMemoryMin, + ExtraArgs: dto.ExtraArgs, // Пустой пароль означает "оставить прежний": наружу мы его не отдаём, // и форма присылает пустую строку, пока его не меняли. AdminPassword: current.AdminPassword, diff --git a/internal/api/handlers_tasks.go b/internal/api/handlers_tasks.go index 42e39b8..4bd59f5 100644 --- a/internal/api/handlers_tasks.go +++ b/internal/api/handlers_tasks.go @@ -236,12 +236,16 @@ func StartBackupScheduler(ctx context.Context, s *Server) { // При обычном старте то же самое делает супервизор. func (s *Server) applyJavaMemory(log func(string)) error { profile := s.cfg.ActiveProfile() - if profile.JavaMemory == "" { + if profile.JavaMemory == "" && profile.JavaMemoryMin == "" { return nil } - if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil { + if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory, profile.JavaMemoryMin); err != nil { return err } - log("Лимит памяти JVM выставлен: " + profile.JavaMemory) + if profile.JavaMemoryMin != "" { + log(fmt.Sprintf("Память JVM: от %s до %s", profile.JavaMemoryMin, profile.JavaMemory)) + } else { + log("Лимит памяти JVM выставлен: " + profile.JavaMemory) + } return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 157112a..5ada4a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,11 @@ type Profile struct { // запуске: файл запуска сервера общий для всех профилей. JavaMemory string `yaml:"java_memory"` + // JavaMemoryMin — -Xms. Пусто (по умолчанию) означает, что JVM занимает + // память по мере надобности: равные -Xms и -Xmx заставляют её забрать всё + // сразу, и на машине с небольшой ОЗУ сервер не поднимется. + JavaMemoryMin string `yaml:"java_memory_min"` + // ExtraArgs — дополнительные аргументы start-server.sh. ExtraArgs []string `yaml:"extra_args"` } diff --git a/internal/pzconfig/java.go b/internal/pzconfig/java.go index 1e80719..c15a337 100644 --- a/internal/pzconfig/java.go +++ b/internal/pzconfig/java.go @@ -6,24 +6,71 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" ) // javaLauncherFile — файл, из которого сервер берёт аргументы JVM. const javaLauncherFile = "ProjectZomboid64.json" -var xmxRe = regexp.MustCompile(`^-Xm[sx]`) +var ( + heapArgRe = regexp.MustCompile(`^-Xm[sx]`) + memoryRe = regexp.MustCompile(`^([0-9]+)([kKmMgG])$`) +) -// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно -// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах -// запуска бесполезно. -func SetJavaMemory(serverDir, memory string) error { - memory = strings.TrimSpace(memory) - if memory == "" { +// ParseMemory переводит запись вида "4g" в байты. Возвращает 0, если строка +// пустая, и ошибку, если формат непонятен. +func ParseMemory(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + match := memoryRe.FindStringSubmatch(value) + if match == nil { + return 0, fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", value) + } + amount, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return 0, fmt.Errorf("не разобран объём памяти %q", value) + } + switch strings.ToLower(match[2]) { + case "k": + return amount * 1024, nil + case "m": + return amount * 1024 * 1024, nil + default: + return amount * 1024 * 1024 * 1024, nil + } +} + +// SetJavaMemory прописывает границы кучи JVM в ProjectZomboid64.json. Именно +// оттуда start-server.sh берёт -Xmx и -Xms, поэтому менять размер памяти в +// аргументах запуска бесполезно. +// +// maxMemory задаёт -Xmx (верхнюю границу), minMemory — -Xms. Пустой minMemory +// означает, что -Xms не выставляется вовсе: JVM тогда занимает память по мере +// надобности. Равные значения удобны тем, что куча не растёт на ходу, но JVM +// обязана занять весь объём при старте — на машине с небольшой ОЗУ сервер +// просто не поднимется. +func SetJavaMemory(serverDir, maxMemory, minMemory string) error { + maxMemory = strings.TrimSpace(maxMemory) + minMemory = strings.TrimSpace(minMemory) + if maxMemory == "" && minMemory == "" { return nil } - if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) { - return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory) + + maxBytes, err := ParseMemory(maxMemory) + if err != nil { + return err + } + minBytes, err := ParseMemory(minMemory) + if err != nil { + return err + } + // JVM с -Xms больше -Xmx не стартует вообще, поэтому ловим это здесь, а не + // в логе упавшего сервера. + if maxBytes > 0 && minBytes > maxBytes { + return fmt.Errorf("начальный размер кучи (%s) больше максимального (%s)", minMemory, maxMemory) } path := filepath.Join(serverDir, javaLauncherFile) @@ -41,14 +88,17 @@ func SetJavaMemory(serverDir, memory string) error { args, _ := doc["vmArgs"].([]any) updated := make([]any, 0, len(args)+2) for _, a := range args { - if str, ok := a.(string); ok && xmxRe.MatchString(str) { + if str, ok := a.(string); ok && heapArgRe.MatchString(str) { continue } updated = append(updated, a) } - // -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост - // кучи в момент, когда на сервере много игроков. - updated = append(updated, "-Xms"+memory, "-Xmx"+memory) + if minMemory != "" { + updated = append(updated, "-Xms"+minMemory) + } + if maxMemory != "" { + updated = append(updated, "-Xmx"+maxMemory) + } doc["vmArgs"] = updated out, err := json.MarshalIndent(doc, "", " ") diff --git a/internal/pzconfig/java_test.go b/internal/pzconfig/java_test.go new file mode 100644 index 0000000..ca7052e --- /dev/null +++ b/internal/pzconfig/java_test.go @@ -0,0 +1,167 @@ +package pzconfig + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestSetJavaMemoryKeepsOnlyXmx: -Xms равным -Xmx заставляет JVM занять весь +// объём при старте, и на машине с небольшой ОЗУ сервер падает с «Failed to +// allocate initial Java heap». Ставим только верхнюю границу. +func TestSetJavaMemoryKeepsOnlyXmx(t *testing.T) { + dir := t.TempDir() + writeLauncher(t, dir, map[string]any{ + "mainClass": "zombie/network/GameServer", + "vmArgs": []any{ + "-Djava.awt.headless=true", + "-XX:+UseZGC", + // Значения от прошлой установки должны замениться. + "-Xms8g", + "-Xmx8g", + }, + }) + + if err := SetJavaMemory(dir, "3g", ""); err != nil { + t.Fatal(err) + } + + args := launcherArgs(t, dir) + var xms, xmx int + for _, a := range args { + switch { + case len(a) > 4 && a[:4] == "-Xms": + xms++ + t.Errorf("в аргументах остался %s — JVM займёт всю память при старте", a) + case a == "-Xmx3g": + xmx++ + } + } + if xms != 0 { + t.Errorf("-Xms не должен добавляться, найдено вхождений: %d", xms) + } + if xmx != 1 { + t.Errorf("-Xmx3g встречается %d раз, ожидалось 1: %v", xmx, args) + } + + // Чужие аргументы должны остаться нетронутыми. + if !contains(args, "-XX:+UseZGC") || !contains(args, "-Djava.awt.headless=true") { + t.Errorf("потеряны исходные аргументы: %v", args) + } +} + +func TestSetJavaMemoryValidates(t *testing.T) { + dir := t.TempDir() + writeLauncher(t, dir, map[string]any{"vmArgs": []any{}}) + + for _, bad := range []string{"4", "4gb", "много", "-4g", "4g; rm -rf /"} { + if err := SetJavaMemory(dir, bad, ""); err == nil { + t.Errorf("значение %q принято, ожидалась ошибка", bad) + } + } + // Пустые значения — «не трогать», это не ошибка. + if err := SetJavaMemory(dir, "", ""); err != nil { + t.Errorf("пустое значение вернуло ошибку: %v", err) + } + for _, good := range []string{"512m", "4g", "8G", "16384M"} { + if err := SetJavaMemory(dir, good, ""); err != nil { + t.Errorf("значение %q отклонено: %v", good, err) + } + } + + // JVM с -Xms больше -Xmx не стартует, поэтому ловим это заранее. + if err := SetJavaMemory(dir, "2g", "4g"); err == nil { + t.Error("начальный размер больше максимального принят, ожидалась ошибка") + } + if err := SetJavaMemory(dir, "4g", "2g"); err != nil { + t.Errorf("корректная пара 2g/4g отклонена: %v", err) + } +} + +func writeLauncher(t *testing.T, dir string, doc map[string]any) { + t.Helper() + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, javaLauncherFile), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func launcherArgs(t *testing.T, dir string) []string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, javaLauncherFile)) + if err != nil { + t.Fatal(err) + } + var doc struct { + VMArgs []string `json:"vmArgs"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + return doc.VMArgs +} + +func contains(list []string, want string) bool { + for _, item := range list { + if item == want { + return true + } + } + return false +} + +// TestSetJavaMemoryWritesXmsWhenAsked: минимальный размер кучи задаётся +// отдельно и попадает в файл, только если его действительно указали. +func TestSetJavaMemoryWritesXmsWhenAsked(t *testing.T) { + dir := t.TempDir() + writeLauncher(t, dir, map[string]any{"vmArgs": []any{"-XX:+UseZGC"}}) + + if err := SetJavaMemory(dir, "6g", "2g"); err != nil { + t.Fatal(err) + } + args := launcherArgs(t, dir) + if !contains(args, "-Xms2g") || !contains(args, "-Xmx6g") { + t.Fatalf("границы кучи не выставлены: %v", args) + } + + // Убрали минимум — из файла он тоже должен уйти. + if err := SetJavaMemory(dir, "6g", ""); err != nil { + t.Fatal(err) + } + args = launcherArgs(t, dir) + for _, a := range args { + if len(a) > 4 && a[:4] == "-Xms" { + t.Errorf("после очистки минимума остался %s: %v", a, args) + } + } + if !contains(args, "-Xmx6g") { + t.Errorf("максимум потерян: %v", args) + } +} + +func TestParseMemory(t *testing.T) { + cases := map[string]int64{ + "": 0, + "512m": 512 * 1024 * 1024, + "4g": 4 * 1024 * 1024 * 1024, + "8G": 8 * 1024 * 1024 * 1024, + "1024k": 1024 * 1024, + } + for input, want := range cases { + got, err := ParseMemory(input) + if err != nil { + t.Errorf("ParseMemory(%q): %v", input, err) + continue + } + if got != want { + t.Errorf("ParseMemory(%q) = %d, ожидалось %d", input, got, want) + } + } + if _, err := ParseMemory("4гб"); err == nil { + t.Error("значение с кириллицей принято") + } +} diff --git a/internal/pzserver/supervisor.go b/internal/pzserver/supervisor.go index a47624d..45f8cb2 100644 --- a/internal/pzserver/supervisor.go +++ b/internal/pzserver/supervisor.go @@ -23,10 +23,13 @@ import ( type State string const ( - StateStopped State = "stopped" - StateStarting State = "starting" - StateRunning State = "running" - StateStopping State = "stopping" + StateStopped State = "stopped" + // StateRestarting — сервер упал и ждёт автоподъёма. Отдельное состояние + // нужно, чтобы это ожидание можно было отменить кнопкой. + StateRestarting State = "restarting" + StateStarting State = "starting" + StateRunning State = "running" + StateStopping State = "stopping" ) // Ошибки, на которые HTTP-слой отвечает осмысленным кодом. @@ -42,20 +45,30 @@ const playersPollInterval = 30 * time.Second // 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"` + 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"` } @@ -89,6 +102,13 @@ type Supervisor struct { captured []string autoQuery bool + // restartCancel закрывается, чтобы отменить ожидание автоперезапуска; + // restartAt — когда оно закончится. + restartCancel chan struct{} + restartAt time.Time + // crashStreak — сколько раз подряд сервер упал сразу после запуска. + crashStreak int + sampler *sysinfo.Sampler stopOnce sync.Once quit chan struct{} @@ -165,9 +185,14 @@ func (s *Supervisor) Status() Status { if s.cmd != nil && s.cmd.Process != nil { st.PID = s.cmd.Process.Pid } - if !s.startedAt.IsZero() && s.state != StateStopped { + 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() @@ -185,9 +210,28 @@ func (s *Supervisor) Status() Status { 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 @@ -204,8 +248,8 @@ func (s *Supervisor) startLocked() error { profile := s.cfg.ActiveProfile() // Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому // выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx. - if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil { - s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error()) + if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory, profile.JavaMemoryMin); err != nil { + s.AppendLog("manager", "Не удалось выставить границы памяти JVM: "+err.Error()) } args := []string{"-servername", profile.ID} @@ -322,6 +366,8 @@ 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 @@ -336,6 +382,15 @@ func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) { 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() @@ -343,14 +398,38 @@ func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) { 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)) - 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) { @@ -394,6 +473,12 @@ func (s *Supervisor) sendQuiet(command string) error { // мир), затем 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