From 9043f9762293c2c57307cac033d6165e5690f8e7 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 22:11:40 -0500 Subject: [PATCH] fix(capture): start and stop the replay sandbox on Windows too The sandbox launched its servers with fork, setpgid and waitpid only. Starting, waiting and stopping now go through one small platform section: POSIX keeps the process group, Windows uses CreateProcess in a job object that is ended as one (and dies with the tool). Environment overrides are cleared in the tool itself, program names get .exe on Windows, and shared folders fall back to a copy where links can't be made. Co-Authored-By: Claude Opus 5.5 --- dCaptureTool/Sandbox.cpp | 189 ++++++++++++++++++++++++++++++++------- dCaptureTool/Sandbox.h | 6 +- 2 files changed, 163 insertions(+), 32 deletions(-) diff --git a/dCaptureTool/Sandbox.cpp b/dCaptureTool/Sandbox.cpp index c0e9e1511..8d1546c93 100644 --- a/dCaptureTool/Sandbox.cpp +++ b/dCaptureTool/Sandbox.cpp @@ -3,13 +3,19 @@ #include #include #include -#include #include #include #include -#include #include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include #include +#endif #include "BinaryPathFinder.h" #include "CaptureBundle.h" @@ -28,6 +34,46 @@ namespace fs = std::filesystem; using json = nlohmann::json; namespace { + // Settings can come from the environment too (dConfig): never in a sandbox. Cleared in the tool itself, so every + // server it starts goes without them. + void ClearOverrides() { + for (const auto* key : { "REPLAY_SANDBOX", "DATABASE_TYPE", "SQLITE_DATABASE_PATH", "MASTER_SERVER_PORT", "WORLD_PORT_START", "AUTH_SERVER_PORT", + "CHAT_SERVER_PORT", "MASTER_IP", "EXTERNAL_IP", "CLIENT_LOCATION", "MYSQL_HOST", "MYSQL_DATABASE", "DLU_CONFIG_DIR" }) { +#ifdef _WIN32 + _putenv_s(key, ""); +#else + unsetenv(key); +#endif + } + } + + // A program's file name on this platform + fs::path Program(const char* name) { +#ifdef _WIN32 + return std::string(name) + ".exe"; +#else + return name; +#endif + } + + uint64_t ProcessId() { +#ifdef _WIN32 + return GetCurrentProcessId(); +#else + return static_cast(getpid()); +#endif + } + +#ifdef _WIN32 + // The running stack: a job object, so master and every server it starts stop together (also when the tool dies) + HANDLE g_Job = nullptr; + + void StopOnSignal(int signal) { + if (g_Job) TerminateJobObject(g_Job, 1); + std::signal(signal, SIG_DFL); + std::raise(signal); + } +#else // The running stack's process group: stopped too if the tool is interrupted or crashes volatile sig_atomic_t g_Running = 0; @@ -36,6 +82,7 @@ namespace { std::signal(signal, SIG_DFL); std::raise(signal); } +#endif /** * Settings every sandbox gets over the server's own files. The first value of a key counts, so each key's line @@ -67,14 +114,48 @@ namespace { std::ofstream(file, std::ios::trunc) << out; } - pid_t Launch(const fs::path& dir, const fs::path& program, const fs::path& output, const std::vector& args = {}) { + /** + * Starts `program` in `dir` with its output appended to `output`. With `group`, it leads a group of its own (the + * stack: master and what it starts), which Stack::Stop ends as one. Returns 0 when it couldn't start. + */ + Sandbox::ProcessHandle Launch(const fs::path& dir, const fs::path& program, const fs::path& output, const std::vector& args, bool group) { +#ifdef _WIN32 + std::string command = "\"" + program.string() + "\""; + for (const auto& arg : args) { + command += " \""; + for (const char c : arg) if (c != '"') command += c; + command += "\""; + } + SECURITY_ATTRIBUTES inherit{ sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE }; + HANDLE log = CreateFileW(output.wstring().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, &inherit, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + STARTUPINFOA startup{}; + startup.cb = sizeof(startup); + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = nullptr; + startup.hStdOutput = startup.hStdError = log; + PROCESS_INFORMATION info{}; + const auto dirText = dir.string(); + const BOOL started = CreateProcessA(nullptr, command.data(), nullptr, nullptr, TRUE, CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, dirText.c_str(), &startup, &info); + if (log != INVALID_HANDLE_VALUE) CloseHandle(log); + if (!started) return 0; + if (group) { + if (!g_Job) { + g_Job = CreateJobObjectA(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject(g_Job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)); + } + AssignProcessToJobObject(g_Job, info.hProcess); + } + ResumeThread(info.hThread); + CloseHandle(info.hThread); + return reinterpret_cast(info.hProcess); +#else const pid_t pid = fork(); + if (pid < 0) return 0; if (pid != 0) return pid; - // The child: its own process group, so the whole stack (master and what it starts) stops together - setpgid(0, 0); - // Settings can come from the environment too (dConfig): not in a sandbox - for (const auto* key : { "REPLAY_SANDBOX", "DATABASE_TYPE", "SQLITE_DATABASE_PATH", "MASTER_SERVER_PORT", "WORLD_PORT_START", "AUTH_SERVER_PORT", - "CHAT_SERVER_PORT", "MASTER_IP", "EXTERNAL_IP", "CLIENT_LOCATION", "MYSQL_HOST", "MYSQL_DATABASE", "DLU_CONFIG_DIR" }) unsetenv(key); + // The child: with `group`, its own process group, so the whole stack (master and what it starts) stops together + if (group) setpgid(0, 0); if (chdir(dir.c_str()) != 0) _exit(127); const int fd = open(output.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0644); if (fd >= 0) { @@ -87,6 +168,33 @@ namespace { argv.push_back(nullptr); execv(program.c_str(), argv.data()); _exit(127); +#endif + } + + // Whether it ended; its exit code in `code` (-1: killed) + bool Ended(Sandbox::ProcessHandle process, bool wait, int& code) { +#ifdef _WIN32 + const auto handle = reinterpret_cast(process); + if (WaitForSingleObject(handle, wait ? INFINITE : 0) != WAIT_OBJECT_0) return false; + DWORD exit = 0; + GetExitCodeProcess(handle, &exit); + code = static_cast(exit); + CloseHandle(handle); + return true; +#else + int status = 0; + if (waitpid(static_cast(process), &status, wait ? 0 : WNOHANG) != static_cast(process)) return false; + code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + return true; +#endif + } + + // A link to something shared with the built servers; a copy where links can't be made (Windows without the right) + void Share(const fs::path& from, const fs::path& to) { + std::error_code ec; + if (fs::is_directory(from)) fs::create_directory_symlink(from, to, ec); + else fs::create_symlink(from, to, ec); + if (ec) fs::copy(from, to, fs::copy_options::recursive, ec); } } @@ -96,7 +204,8 @@ namespace Sandbox { stack->m_Options = options; stack->m_Keep = options.keep; std::error_code ec; - const auto stamp = std::to_string(std::chrono::system_clock::now().time_since_epoch().count() / 1000000) + "-" + std::to_string(getpid()); + const auto stamp = std::to_string(std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()) + "-" + + std::to_string(ProcessId()); stack->m_Dir = fs::absolute(options.root / ("sandbox-" + stamp)); const auto& dir = stack->m_Dir; if (!fs::create_directories(dir / "resServer", ec) || !fs::create_directories(dir / "logs", ec)) { @@ -105,15 +214,15 @@ namespace Sandbox { } // Binaries are copied: they find their settings and database next to themselves for (const auto* name : { "MasterServer", "AuthServer", "ChatServer", "WorldServer" }) { - fs::copy_file(options.serverDir / name, dir / name, ec); + fs::copy_file(options.serverDir / Program(name), dir / Program(name), ec); if (ec) { - error = "Can't copy " + (options.serverDir / name).string() + ": " + ec.message(); + error = "Can't copy " + (options.serverDir / Program(name)).string() + ": " + ec.message(); return nullptr; } } - fs::copy_file(BinaryPathFinder::GetBinaryDir() / "CaptureTool", dir / "CaptureTool", ec); - for (const auto* name : { "migrations", "navmeshes", "vanity", "blocklist.dcf", "libmariadbcpp.so" }) { - if (fs::exists(options.serverDir / name)) fs::create_symlink(fs::absolute(options.serverDir / name), dir / name, ec); + fs::copy_file(BinaryPathFinder::GetBinaryDir() / Program("CaptureTool"), dir / Program("CaptureTool"), ec); + for (const auto* name : { "migrations", "navmeshes", "vanity", "blocklist.dcf", "libmariadbcpp.so", "libmariadbcpp.dylib", "mariadbcpp.dll", "plugin" }) { + if (fs::exists(options.serverDir / name)) Share(fs::absolute(options.serverDir / name), dir / name); } for (const auto* name : { "sharedconfig.ini", "masterconfig.ini", "authconfig.ini", "chatconfig.ini", "worldconfig.ini" }) { fs::copy_file(options.serverDir / name, dir / name, ec); @@ -172,11 +281,11 @@ namespace Sandbox { bool Stack::Setup(const fs::path& bundle, const std::string& username, const std::string& password, bool characters, json& ids, std::string& error) { const auto out = m_Dir / "setup.json"; - const pid_t pid = Launch(m_Dir, m_Dir / "CaptureTool", m_Dir / "logs" / "setup.out", - { "sandbox-setup", fs::absolute(bundle).string(), username, password, characters ? "1" : "0", out.string() }); - int status = 0; - waitpid(pid, &status, 0); - if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + ClearOverrides(); + const auto process = Launch(m_Dir, m_Dir / Program("CaptureTool"), m_Dir / "logs" / "setup.out", + { "sandbox-setup", fs::absolute(bundle).string(), username, password, characters ? "1" : "0", out.string() }, false); + int code = -1; + if (!process || !Ended(process, true, code) || code != 0) { error = "Setting up the sandbox failed (see " + (m_Dir / "logs" / "setup.out").string() + ")"; return false; } @@ -191,13 +300,20 @@ namespace Sandbox { bool Stack::Start(std::string& error) { for (const int signal : { SIGINT, SIGTERM, SIGSEGV, SIGABRT }) std::signal(signal, StopOnSignal); - m_Master = Launch(m_Dir, m_Dir / "MasterServer", m_Dir / "logs" / "master.out"); - g_Running = m_Master; + ClearOverrides(); + m_Master = Launch(m_Dir, m_Dir / Program("MasterServer"), m_Dir / "logs" / "master.out", {}, true); + if (!m_Master) { + error = "Couldn't start the sandbox's master server"; + return false; + } +#ifndef _WIN32 + g_Running = static_cast(m_Master); +#endif // Auth answering is the sign the stack is up (master starts it after chat and the character select world) const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(120); while (std::chrono::steady_clock::now() < until) { - int status = 0; - if (waitpid(m_Master, &status, WNOHANG) == m_Master) { + int code = 0; + if (Ended(m_Master, false, code)) { m_Master = 0; error = "The sandbox's master server stopped (see " + (m_Dir / "logs").string() + ")"; return false; @@ -216,15 +332,28 @@ namespace Sandbox { } void Stack::Stop() { - if (m_Master <= 0) return; - kill(-m_Master, SIGTERM); + if (!m_Master) return; +#ifdef _WIN32 + // Servers have no console to be asked to stop through: end the job (master and every server it started) + if (g_Job) { + TerminateJobObject(g_Job, 0); + CloseHandle(g_Job); + g_Job = nullptr; + } + int code = 0; + Ended(m_Master, true, code); +#else + const auto group = static_cast(m_Master); + kill(-group, SIGTERM); const auto until = std::chrono::steady_clock::now() + std::chrono::seconds(15); - int status = 0; - while (waitpid(m_Master, &status, WNOHANG) == 0 && std::chrono::steady_clock::now() < until) std::this_thread::sleep_for(std::chrono::milliseconds(100)); - kill(-m_Master, SIGKILL); - waitpid(m_Master, &status, 0); - m_Master = 0; + int code = 0; + bool ended = false; + while (!(ended = Ended(m_Master, false, code)) && std::chrono::steady_clock::now() < until) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + kill(-group, SIGKILL); + if (!ended) Ended(m_Master, true, code); g_Running = 0; +#endif + m_Master = 0; } int SetupCommand(const fs::path& bundlePath, const std::string& username, const std::string& password, bool characters, const fs::path& out) { diff --git a/dCaptureTool/Sandbox.h b/dCaptureTool/Sandbox.h index 359fcc428..fc156d374 100644 --- a/dCaptureTool/Sandbox.h +++ b/dCaptureTool/Sandbox.h @@ -4,7 +4,6 @@ #include #include #include -#include #include "json.hpp" @@ -16,6 +15,9 @@ * read-only; CDServer.sqlite is copied. The folder is deleted afterwards unless it is kept. */ namespace Sandbox { + // A started process: its pid, or its process handle on Windows (0: none) + using ProcessHandle = intptr_t; + struct Options { std::filesystem::path serverDir; // built servers (MasterServer, AuthServer, ChatServer, WorldServer, migrations, ...) std::filesystem::path root; // sandboxes are made in here @@ -47,7 +49,7 @@ namespace Sandbox { private: Options m_Options; std::filesystem::path m_Dir; - pid_t m_Master{}; + ProcessHandle m_Master{}; bool m_Keep{}; };