Регистрацию в Steam сервер печатает задолго до того, как мир загружен, и по ней панель показывала «Работает» на сервере, который ещё никого не пустит. Теперь готовность распознаётся только по «*** SERVER STARTED ****», а пока идёт загрузка, состояние остаётся «Запускается» с пояснением под ним и счётчиком времени загрузки. Если строки о готовности так и не будет (своя версия, свои моды), через пятнадцать минут менеджер сочтёт сервер запущенным и скажет об этом в консоли: иначе панель навсегда осталась бы без опроса игроков. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
594 lines
20 KiB
Go
594 lines
20 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
|
|
|
|
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
|
|
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
|
|
// работающим хуже, чем подождать.
|
|
const startupGrace = 15 * time.Minute
|
|
|
|
// 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) }
|
|
|
|
// 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()
|
|
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
|
time.Since(s.startedAt) > startupGrace
|
|
if overdue {
|
|
s.state = StateRunning
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if overdue {
|
|
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
}
|