tinymux/mux/proxy/process_manager.cpp
Stephen Dennis cb4c101125 fix: resolve Pass 4 Hydra Medium audit defects #1095–#1102
- #1095: WS RSV + control FIN/len≤125 before ext-len (no large PING→PONG)
- #1096: cap writeBuffers_ (256 KiB, close slow FD); subscriber queues drop oldest at 256
- #1097: gRPC/grpc-web login lockout by peer IP; /passwd requires old password
- #1098: RAND_bytes hard-fail; password/scrollback salts via CSPRNG
- #1099: ProcessManager::stopAndWait with SIGKILL escalate; restart + dtor wait/reap
- #1100: scrollback_lines applied on session create/restore; max_sessions_per_account enforced
- #1101: telnet IAC SB reassembly capped at 64 KiB
- #1102: /metrics loopback-only; ListGames/GetGameStatus require session

proxy_regression: ok (RSV, large PING, SB cap + prior WS cases)
hydra: builds with GRPC=1
2026-07-24 18:22:17 -06:00

429 lines
13 KiB
C++

#include "process_manager.h"
#include "hydra_log.h"
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <vector>
#if defined(_WIN32)
#include <windows.h>
#else
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/wait.h>
#include <time.h>
#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<std::string> 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<char*>(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<unsigned long>(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<unsigned long>(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<DWORD>(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<unsigned long>(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<std::string> ProcessManager::reapChildren() {
std::vector<std::string> 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<unsigned long>(proc.pid),
static_cast<unsigned long>(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<unsigned long>(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<std::string> ProcessManager::reapChildren() {
std::vector<std::string> 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