fix(backend): replace mutex with atomic state machine in ProcessManager and SteamCmdManager

- ProcessManager: atomic.Int32 state enum (idle/starting/running/stopping)
  eliminates race between IsRunning check and Start/Stop
- SteamCmdManager: atomic.Bool with CompareAndSwap for same protection
- Restart() now propagates Stop() errors instead of swallowing them
This commit is contained in:
MrFastwind
2026-07-23 20:15:11 +02:00
parent 9f232b5225
commit 6e628a0bba
2 changed files with 40 additions and 30 deletions
+31 -10
View File
@@ -9,10 +9,20 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"arma3-web-server/internal/models"
)
type procState int32
const (
procIdle procState = 0
procStarting procState = 1
procRunning procState = 2
procStopping procState = 3
)
type ProcessManager struct {
serverfileDir string
modsDir string
@@ -23,7 +33,8 @@ type ProcessManager struct {
configs *ConfigManager
streamer *LogStreamer
proc *runningProcess
mu sync.RWMutex
state atomic.Int32
mu sync.Mutex
}
type runningProcess struct {
@@ -54,26 +65,23 @@ func (pm *ProcessManager) ProfilesDir() string {
}
func (pm *ProcessManager) IsRunning() bool {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.proc != nil
return pm.state.Load() == int32(procRunning)
}
func (pm *ProcessManager) Start() error {
pm.mu.Lock()
if pm.proc != nil {
pm.mu.Unlock()
return fmt.Errorf("server already running")
if !pm.state.CompareAndSwap(int32(procIdle), int32(procStarting)) {
return fmt.Errorf("server already running or changing state")
}
pm.mu.Unlock()
s, err := pm.settings.Load()
if err != nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("load settings: %w", err)
}
s.WasRunning = true
if err := pm.settings.Save(s); err != nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("save was_running: %w", err)
}
@@ -90,6 +98,7 @@ func (pm *ProcessManager) Start() error {
parts := splitArgs(full)
if len(parts) == 0 {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("empty command after %%command%% substitution")
}
autoArgs := pm.buildAutoArgs(s)
@@ -102,16 +111,19 @@ func (pm *ProcessManager) Start() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
pm.state.Store(int32(procIdle))
return fmt.Errorf("start: %w", err)
}
@@ -119,6 +131,7 @@ func (pm *ProcessManager) Start() error {
pm.mu.Lock()
pm.proc = &runningProcess{cmd: cmd, cancel: cancel, exited: exited}
pm.mu.Unlock()
pm.state.Store(int32(procRunning))
go pm.streamer.Stream("server", stdout, "")
go pm.streamer.Stream("server", stderr, "")
@@ -128,6 +141,7 @@ func (pm *ProcessManager) Start() error {
pm.mu.Lock()
pm.proc = nil
pm.mu.Unlock()
pm.state.Store(int32(procIdle))
close(exited)
pm.streamer.Broadcast("server", "[SERVER_PROCESS_EXITED]")
}()
@@ -136,11 +150,16 @@ func (pm *ProcessManager) Start() error {
}
func (pm *ProcessManager) Stop() error {
if !pm.state.CompareAndSwap(int32(procRunning), int32(procStopping)) {
return fmt.Errorf("server not running")
}
pm.mu.Lock()
rp := pm.proc
pm.mu.Unlock()
if rp == nil {
pm.state.Store(int32(procIdle))
return fmt.Errorf("server not running")
}
@@ -157,7 +176,9 @@ func (pm *ProcessManager) Stop() error {
}
func (pm *ProcessManager) Restart() error {
_ = pm.Stop()
if err := pm.Stop(); err != nil {
return fmt.Errorf("stop: %w", err)
}
return pm.Start()
}
+9 -20
View File
@@ -6,7 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
@@ -24,9 +24,8 @@ const arma3AppID = "233780"
type SteamCmdManager struct {
serverfileDir string
streamer *LogStreamer
mu sync.Mutex
running atomic.Bool
cancel context.CancelFunc
running bool
}
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
@@ -37,18 +36,13 @@ func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdMa
}
func (s *SteamCmdManager) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
return s.running.Load()
}
func (s *SteamCmdManager) UpdateGame(branch, user string) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
if !s.running.CompareAndSwap(false, true) {
return fmt.Errorf("steamcmd already running")
}
s.mu.Unlock()
args := []string{
"+force_install_dir", s.serverfileDir,
@@ -68,12 +62,9 @@ func (s *SteamCmdManager) DownloadMod(modID string) error {
}
func (s *SteamCmdManager) DownloadMods(modIDs []string) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
if !s.running.CompareAndSwap(false, true) {
return fmt.Errorf("steamcmd already running")
}
s.mu.Unlock()
if len(modIDs) == 0 {
return fmt.Errorf("no mod ids provided")
@@ -104,33 +95,31 @@ func (s *SteamCmdManager) run(label string, args []string) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
cancel()
s.running.Store(false)
return fmt.Errorf("start steamcmd: %w", err)
}
s.mu.Lock()
s.running = true
s.cancel = cancel
s.mu.Unlock()
go s.streamer.Stream("steamcmd", stdout, "")
go s.streamer.Stream("steamcmd", stderr, "")
go func() {
err := cmd.Wait()
s.mu.Lock()
s.running = false
s.running.Store(false)
s.cancel = nil
s.mu.Unlock()
if err == nil {
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
} else {