#include "process_manager.h" #include "hydra_log.h" #include #include #include #include #if defined(_WIN32) #include #else #include #include #include #include #include #endif ProcessManager::ProcessManager() { } ProcessManager::~ProcessManager() { // #1099: wait/reap (with kill escalate) so local games are not left // orphaned and zombies are reaped on Hydra exit. std::vector names; names.reserve(processes_.size()); for (const auto& [name, proc] : processes_) { (void)proc; names.push_back(name); } for (const auto& name : names) { stopAndWait(name, 3); } } #if defined(_WIN32) // Check if a process handle is still running (non-blocking). static bool isProcessAlive(HANDLE h) { if (!h) return false; DWORD exitCode = 0; if (!GetExitCodeProcess(h, &exitCode)) return false; return exitCode == STILL_ACTIVE; } bool ProcessManager::startGame(const GameConfig& game, std::string& errorMsg) { if (game.type != GameType::Local) { errorMsg = "game '" + game.name + "' is not a local game"; return false; } if (game.binary.empty()) { errorMsg = "no binary configured for game '" + game.name + "'"; return false; } // Already running? auto it = processes_.find(game.name); if (it != processes_.end() && it->second.hProcess) { if (isProcessAlive(it->second.hProcess)) { errorMsg = "game '" + game.name + "' is already running (pid " + std::to_string(it->second.pid) + ")"; return false; } // Already exited — clean up CloseHandle(it->second.hProcess); processes_.erase(it); } STARTUPINFOA si; PROCESS_INFORMATION pi; memset(&si, 0, sizeof(si)); si.cb = sizeof(si); memset(&pi, 0, sizeof(pi)); // Build command line (just the binary for now) std::string cmdLine = game.binary; const char* workDir = game.workdir.empty() ? nullptr : game.workdir.c_str(); if (!CreateProcessA( nullptr, // lpApplicationName const_cast(cmdLine.c_str()), // lpCommandLine nullptr, // lpProcessAttributes nullptr, // lpThreadAttributes FALSE, // bInheritHandles CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, nullptr, // lpEnvironment workDir, // lpCurrentDirectory &si, &pi)) { errorMsg = "CreateProcess failed: error " + std::to_string(GetLastError()); return false; } // Don't need the thread handle CloseHandle(pi.hThread); ManagedProcess proc; proc.hProcess = pi.hProcess; proc.pid = pi.dwProcessId; proc.gameName = game.name; proc.binary = game.binary; proc.workdir = game.workdir; proc.started = time(nullptr); processes_[game.name] = proc; LOG_INFO("Started game '%s' (pid %lu)", game.name.c_str(), static_cast(proc.pid)); return true; } bool ProcessManager::stopGame(const std::string& gameName) { auto it = processes_.find(gameName); if (it == processes_.end() || !it->second.hProcess) return false; ManagedProcess& proc = it->second; if (!isProcessAlive(proc.hProcess)) { CloseHandle(proc.hProcess); processes_.erase(it); return false; } // Send Ctrl+Break to the process group (graceful shutdown) GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, proc.pid); proc.stopping = true; proc.stopSentAt = time(nullptr); LOG_INFO("Sent CTRL_BREAK to game '%s' (pid %lu)", gameName.c_str(), static_cast(proc.pid)); return true; } bool ProcessManager::stopAndWait(const std::string& gameName, int timeoutSec) { // #1099: graceful stop, poll until dead, then hard-kill and reap. stopGame(gameName); auto it = processes_.find(gameName); if (it == processes_.end() || !it->second.hProcess) { return true; } if (timeoutSec < 1) { timeoutSec = 1; } DWORD waitMs = static_cast(timeoutSec) * 1000; DWORD rc = WaitForSingleObject(it->second.hProcess, waitMs); if (rc == WAIT_OBJECT_0) { CloseHandle(it->second.hProcess); processes_.erase(it); return true; } LOG_WARN("Game '%s' (pid %lu) still alive after %ds — terminating", gameName.c_str(), static_cast(it->second.pid), timeoutSec); TerminateProcess(it->second.hProcess, 1); WaitForSingleObject(it->second.hProcess, 3000); CloseHandle(it->second.hProcess); processes_.erase(it); return true; } bool ProcessManager::restartGame(const GameConfig& game, std::string& errorMsg) { // #1099: wait for the old process to exit before startGame. stopAndWait(game.name, 5); return startGame(game, errorMsg); } bool ProcessManager::isRunning(const std::string& gameName) const { auto it = processes_.find(gameName); if (it == processes_.end() || !it->second.hProcess) return false; return isProcessAlive(it->second.hProcess); } DWORD ProcessManager::getPid(const std::string& gameName) const { auto it = processes_.find(gameName); if (it == processes_.end()) return 0; return it->second.pid; } std::vector ProcessManager::reapChildren() { std::vector exited; for (auto it = processes_.begin(); it != processes_.end(); ) { ManagedProcess& proc = it->second; if (!proc.hProcess) { it = processes_.erase(it); continue; } if (!isProcessAlive(proc.hProcess)) { DWORD exitCode = 0; GetExitCodeProcess(proc.hProcess, &exitCode); LOG_INFO("Game '%s' (pid %lu) exited with code %lu", proc.gameName.c_str(), static_cast(proc.pid), static_cast(exitCode)); CloseHandle(proc.hProcess); exited.push_back(proc.gameName); it = processes_.erase(it); } else { // Force-kill after 10 seconds if stop was requested if (proc.stopping && time(nullptr) - proc.stopSentAt > 10) { LOG_WARN("Game '%s' (pid %lu) didn't exit after CTRL_BREAK, " "terminating", proc.gameName.c_str(), static_cast(proc.pid)); TerminateProcess(proc.hProcess, 1); proc.stopSentAt = time(nullptr); } ++it; } } return exited; } #else // POSIX bool ProcessManager::startGame(const GameConfig& game, std::string& errorMsg) { if (game.type != GameType::Local) { errorMsg = "game '" + game.name + "' is not a local game"; return false; } if (game.binary.empty()) { errorMsg = "no binary configured for game '" + game.name + "'"; return false; } // Already running? auto it = processes_.find(game.name); if (it != processes_.end() && it->second.pid > 0) { // Check if still alive int status = 0; pid_t ret = waitpid(it->second.pid, &status, WNOHANG); if (ret == 0) { errorMsg = "game '" + game.name + "' is already running (pid " + std::to_string(it->second.pid) + ")"; return false; } // Already exited — clean up processes_.erase(it); } pid_t pid = fork(); if (pid < 0) { errorMsg = "fork failed: " + std::string(strerror(errno)); return false; } if (pid == 0) { // Child process if (!game.workdir.empty()) { if (chdir(game.workdir.c_str()) != 0) { _exit(127); } } // Close stdin, redirect stdout/stderr to /dev/null // (game's own logging handles output) close(STDIN_FILENO); open("/dev/null", 0); // fd 0 // Create new session so the game doesn't get our signals setsid(); execl(game.binary.c_str(), game.binary.c_str(), nullptr); _exit(127); // exec failed } // Parent ManagedProcess proc; proc.pid = pid; proc.gameName = game.name; proc.binary = game.binary; proc.workdir = game.workdir; proc.started = time(nullptr); processes_[game.name] = proc; LOG_INFO("Started game '%s' (pid %d)", game.name.c_str(), pid); return true; } bool ProcessManager::stopGame(const std::string& gameName) { auto it = processes_.find(gameName); if (it == processes_.end() || it->second.pid <= 0) return false; ManagedProcess& proc = it->second; // Check still alive int status = 0; pid_t ret = waitpid(proc.pid, &status, WNOHANG); if (ret != 0) { // Already dead processes_.erase(it); return false; } kill(proc.pid, SIGTERM); proc.stopping = true; proc.stopSentAt = time(nullptr); LOG_INFO("Sent SIGTERM to game '%s' (pid %d)", gameName.c_str(), proc.pid); return true; } bool ProcessManager::stopAndWait(const std::string& gameName, int timeoutSec) { // #1099: SIGTERM, poll waitpid, SIGKILL escalate, reap. stopGame(gameName); auto it = processes_.find(gameName); if (it == processes_.end() || it->second.pid <= 0) { return true; } if (timeoutSec < 1) { timeoutSec = 1; } const pid_t pid = it->second.pid; auto pollUntil = [&](int seconds) -> bool { const time_t until = time(nullptr) + seconds; for (;;) { int status = 0; pid_t ret = waitpid(pid, &status, WNOHANG); if (ret != 0) { // reaped (ret > 0) or no such child (ret < 0) processes_.erase(gameName); return true; } if (time(nullptr) >= until) { return false; } struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = 50L * 1000L * 1000L; nanosleep(&ts, nullptr); } }; if (pollUntil(timeoutSec)) { return true; } LOG_WARN("Game '%s' (pid %d) still alive after %ds — SIGKILL", gameName.c_str(), pid, timeoutSec); kill(pid, SIGKILL); if (pollUntil(3)) { return true; } LOG_ERROR("Game '%s' (pid %d) unreaped after SIGKILL", gameName.c_str(), pid); processes_.erase(gameName); return false; } bool ProcessManager::restartGame(const GameConfig& game, std::string& errorMsg) { // #1099: wait for the old process to exit before startGame so // "already running" is not a flaky race. stopAndWait(game.name, 5); return startGame(game, errorMsg); } bool ProcessManager::isRunning(const std::string& gameName) const { auto it = processes_.find(gameName); if (it == processes_.end() || it->second.pid <= 0) return false; int status = 0; pid_t ret = waitpid(it->second.pid, &status, WNOHANG); return ret == 0; // 0 = still running } pid_t ProcessManager::getPid(const std::string& gameName) const { auto it = processes_.find(gameName); if (it == processes_.end()) return 0; return it->second.pid; } std::vector ProcessManager::reapChildren() { std::vector exited; for (auto it = processes_.begin(); it != processes_.end(); ) { ManagedProcess& proc = it->second; if (proc.pid <= 0) { it = processes_.erase(it); continue; } int status = 0; pid_t ret = waitpid(proc.pid, &status, WNOHANG); if (ret > 0) { if (WIFEXITED(status)) { LOG_INFO("Game '%s' (pid %d) exited with status %d", proc.gameName.c_str(), proc.pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { LOG_INFO("Game '%s' (pid %d) killed by signal %d", proc.gameName.c_str(), proc.pid, WTERMSIG(status)); } exited.push_back(proc.gameName); it = processes_.erase(it); } else { // Check for stuck stop — SIGKILL after 10 seconds if (proc.stopping && time(nullptr) - proc.stopSentAt > 10) { LOG_WARN("Game '%s' (pid %d) didn't exit after SIGTERM, sending SIGKILL", proc.gameName.c_str(), proc.pid); kill(proc.pid, SIGKILL); proc.stopSentAt = time(nullptr); // reset timer } ++it; } } return exited; } #endif // _WIN32