From 6e628a0bba20e3192bda1ee16d545bd24856876b Mon Sep 17 00:00:00 2001 From: MrFastwind Date: Thu, 23 Jul 2026 20:15:11 +0200 Subject: [PATCH] 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 --- backend/internal/services/server_process.go | 41 ++++++++++++++++----- backend/internal/services/steamcmd.go | 29 +++++---------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/backend/internal/services/server_process.go b/backend/internal/services/server_process.go index bde235d..f201231 100644 --- a/backend/internal/services/server_process.go +++ b/backend/internal/services/server_process.go @@ -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() } diff --git a/backend/internal/services/steamcmd.go b/backend/internal/services/steamcmd.go index f29770b..b961392 100644 --- a/backend/internal/services/steamcmd.go +++ b/backend/internal/services/steamcmd.go @@ -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 {