#include "config_file.hpp"
#include "engine_worker.hpp"
#include "game_http_server.hpp"
#include "game_manager.hpp"
#include "mcp_server.hpp"
#include "server_config.hpp"
#include <cstdio>
#include <cstdlib>
#include <chrono>
#include <csignal>
#include <exception>
#include <filesystem>
#include <limits>
#include <optional>
#include <string>
#include <sys/resource.h>
#include <thread>
#include <utility>
namespace
{
volatile std::sig_atomic_t stop_requested = 0;
void requestStop(int)
{
stop_requested = 1;
}
enum class ParseResult
{
SUCCESS,
HELP,
ERROR,
};
bool parsePositive(const std::string& text, std::size_t& value)
{
if(text.empty() || text.front() == '-')
{
return false;
}
try
{
std::size_t consumed = 0;
const unsigned long long parsed = std::stoull(text, &consumed);
if(consumed != text.size() || parsed == 0
|| parsed > static_cast<unsigned long long>(
std::numeric_limits<std::size_t>::max())
|| parsed > static_cast<unsigned long long>(
std::numeric_limits<std::int64_t>::max()))
{
return false;
}
value = static_cast<std::size_t>(parsed);
return true;
}
catch(const std::exception&)
{
return false;
}
}
bool validResourceBounds(const nethack_mcp::ServerConfig& config)
{
constexpr std::size_t MAX_ACTIVE_GAMES = 1024;
constexpr std::size_t MAX_HTTP_WORKERS = 1024;
constexpr std::size_t MAX_HTTP_CONNECTIONS = 1'000'000;
constexpr std::size_t MAX_RATE_LIMIT_CLIENTS = 1'000'000;
constexpr std::size_t MAX_MCP_BODY_BYTES = 64U * 1024U * 1024U;
constexpr std::size_t MAX_WORKER_OUTPUT_BYTES = 1024U * 1024U * 1024U;
constexpr std::int64_t MAX_DURATION_SECONDS = 315'360'000;
const auto valid_duration = [](std::chrono::seconds duration) {
return duration.count() > 0
&& duration.count() <= MAX_DURATION_SECONDS;
};
return config.max_active_games <= MAX_ACTIVE_GAMES
&& config.max_concurrent_requests <= MAX_HTTP_WORKERS
&& config.max_open_connections <= MAX_HTTP_CONNECTIONS
&& config.new_games_per_client <= 1'000'000
&& config.control_failures_per_client <= 10'000
&& config.max_rate_limit_clients <= MAX_RATE_LIMIT_CLIENTS
&& config.max_mcp_body_bytes <= MAX_MCP_BODY_BYTES
&& config.max_worker_output_bytes <= MAX_WORKER_OUTPUT_BYTES
&& valid_duration(config.new_game_rate_window)
&& valid_duration(config.control_failure_window)
&& valid_duration(config.idle_timeout)
&& valid_duration(config.max_game_duration)
&& valid_duration(config.lifecycle_sweep_interval);
}
bool parsePort(const std::string& text, int& port)
{
std::size_t parsed = 0;
if(!parsePositive(text, parsed) || parsed > 65535)
{
return false;
}
port = static_cast<int>(parsed);
return true;
}
void printUsage()
{
std::fputs(
"Usage: nethack_mcp [options]\n"
" --config PATH Read TOML configuration\n"
" --listen-address ADDRESS HTTP address or UDS path\n"
" --port PORT HTTP port (8765)\n"
" --data-root PATH Temporary game files\n"
" --database PATH Persistent SQLite records\n"
" --runtime-dir PATH NetHack runtime data files\n"
" --public-base-url URL Canonical URL ending in /\n"
" --max-active-games N Active game capacity\n"
" --new-games-per-client N Creation quota per rate window\n"
" --new-game-rate-window-seconds N Creation quota window\n"
" --control-failures-per-client N Bad-token limit (10)\n"
" --control-failure-window-seconds N Bad-token window (60)\n"
" --max-rate-limit-clients N Tracked client cap (4096)\n"
" --max-concurrent-requests N HTTP request worker limit\n"
" --max-open-connections N Total HTTP connection limit\n"
" --idle-timeout-seconds N Idle game lifetime (600)\n"
" --max-game-duration-seconds N Absolute game lifetime (86400)\n"
" --lifecycle-sweep-seconds N Expiry sweep interval (15)\n"
" --max-mcp-body-bytes N MCP request size (1048576)\n"
" --max-worker-output-bytes N Worker log byte limit\n"
" --help Show this help\n",
stdout);
}
ParseResult parseOptions(int argc, char* argv[],
nethack_mcp::ServerConfig& config,
std::string& error)
{
config.data_root = std::filesystem::temp_directory_path()
/ "nethack-mcp";
const char* home = std::getenv("HOME");
const std::filesystem::path home_path = home != nullptr && home[0] != '\0'
? std::filesystem::path(home) : std::filesystem::current_path();
config.database_path = home_path / ".local" / "share" / "nethack-mcp"
/ "games.sqlite3";
config.runtime_directory = NETHACK_RUNTIME_DIR;
for(int index = 1; index < argc; ++index)
{
if(std::string(argv[index]) == "--help")
{
printUsage();
return ParseResult::HELP;
}
}
std::optional<std::filesystem::path> config_path;
for(int index = 1; index < argc; ++index)
{
if(std::string(argv[index]) == "--config")
{
if(config_path.has_value() || index + 1 >= argc)
{
error = "--config requires exactly one file path";
return ParseResult::ERROR;
}
config_path = argv[++index];
}
}
nethack_mcp::ConfigExplicitSettings explicit_settings;
if(config_path.has_value()
&& !nethack_mcp::readConfigFile(*config_path, config,
explicit_settings, error))
{
return ParseResult::ERROR;
}
for(int index = 1; index < argc; ++index)
{
const std::string argument = argv[index];
if(argument == "--config")
{
++index;
continue;
}
if(index + 1 >= argc)
{
return ParseResult::ERROR;
}
const std::string value = argv[++index];
std::size_t number = 0;
if(argument == "--listen-address")
{
if(value.empty()) return ParseResult::ERROR;
config.listen_address = value;
}
else if(argument == "--port")
{
if(!parsePort(value, config.port)) return ParseResult::ERROR;
}
else if(argument == "--data-root")
{
config.data_root = value;
}
else if(argument == "--database")
{
config.database_path = value;
}
else if(argument == "--runtime-dir")
{
config.runtime_directory = value;
}
else if(argument == "--public-base-url")
{
config.public_base_url = value;
explicit_settings.public_base_url = true;
}
else if(argument == "--max-active-games")
{
if(!parsePositive(value, config.max_active_games))
return ParseResult::ERROR;
explicit_settings.max_active_games = true;
}
else if(argument == "--new-games-per-client")
{
if(!parsePositive(value, config.new_games_per_client))
return ParseResult::ERROR;
explicit_settings.new_games_per_client = true;
}
else if(argument == "--new-game-rate-window-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.new_game_rate_window = std::chrono::seconds(number);
explicit_settings.new_game_rate_window = true;
}
else if(argument == "--control-failures-per-client")
{
if(!parsePositive(value, config.control_failures_per_client))
return ParseResult::ERROR;
}
else if(argument == "--control-failure-window-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.control_failure_window = std::chrono::seconds(number);
}
else if(argument == "--max-rate-limit-clients")
{
if(!parsePositive(value, config.max_rate_limit_clients))
return ParseResult::ERROR;
explicit_settings.max_rate_limit_clients = true;
}
else if(argument == "--max-concurrent-requests")
{
if(!parsePositive(value, config.max_concurrent_requests))
return ParseResult::ERROR;
explicit_settings.max_concurrent_requests = true;
}
else if(argument == "--max-open-connections")
{
if(!parsePositive(value, config.max_open_connections))
return ParseResult::ERROR;
explicit_settings.max_open_connections = true;
}
else if(argument == "--idle-timeout-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.idle_timeout = std::chrono::seconds(number);
}
else if(argument == "--max-game-duration-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.max_game_duration = std::chrono::seconds(number);
}
else if(argument == "--lifecycle-sweep-seconds")
{
if(!parsePositive(value, number)) return ParseResult::ERROR;
config.lifecycle_sweep_interval = std::chrono::seconds(number);
}
else if(argument == "--max-mcp-body-bytes")
{
if(!parsePositive(value, config.max_mcp_body_bytes))
return ParseResult::ERROR;
}
else if(argument == "--max-worker-output-bytes")
{
if(!parsePositive(value, config.max_worker_output_bytes))
return ParseResult::ERROR;
explicit_settings.max_worker_output_bytes = true;
}
else
{
return ParseResult::ERROR;
}
}
if(!explicit_settings.public_base_url)
{
config.public_base_url = "http://127.0.0.1:"
+ std::to_string(config.port) + "/";
}
if(config.public_base_url.empty()
|| (config.public_base_url.rfind("https://", 0) != 0
&& config.public_base_url.rfind("http://", 0) != 0)
|| config.public_base_url.back() != '/')
{
return ParseResult::ERROR;
}
const std::size_t scheme_end = config.public_base_url.find("://");
const std::size_t path_start = config.public_base_url.find(
'/', scheme_end + 3);
if(path_start != config.public_base_url.size() - 1)
{
return ParseResult::ERROR;
}
const std::string authority = config.public_base_url.substr(
scheme_end + 3, path_start - (scheme_end + 3));
if(authority.empty())
{
return ParseResult::ERROR;
}
const std::string host_name = authority.substr(0, authority.find(':'));
const bool loopback = host_name == "127.0.0.1"
|| host_name == "localhost";
if(!loopback && config.public_base_url.rfind("https://", 0) != 0)
{
return ParseResult::ERROR;
}
if(!loopback
&& (!explicit_settings.max_active_games
|| !explicit_settings.new_games_per_client
|| !explicit_settings.new_game_rate_window
|| !explicit_settings.max_rate_limit_clients
|| !explicit_settings.max_concurrent_requests
|| !explicit_settings.max_open_connections
|| !explicit_settings.max_worker_output_bytes))
{
return ParseResult::ERROR;
}
if(config.max_open_connections <= config.max_concurrent_requests
|| !validResourceBounds(config))
{
return ParseResult::ERROR;
}
return ParseResult::SUCCESS;
}
bool configureDescriptorLimit(const nethack_mcp::ServerConfig& config)
{
struct rlimit limits{};
if(::getrlimit(RLIMIT_NOFILE, &limits) != 0)
{
return false;
}
constexpr rlim_t RESERVED_DESCRIPTORS = 64;
const rlim_t maximum = std::numeric_limits<rlim_t>::max();
const rlim_t active_games = static_cast<rlim_t>(config.max_active_games);
const rlim_t open_connections =
static_cast<rlim_t>(config.max_open_connections);
if(static_cast<std::size_t>(active_games) != config.max_active_games
|| static_cast<std::size_t>(open_connections)
!= config.max_open_connections
|| active_games > (maximum - RESERVED_DESCRIPTORS) / 2)
{
return false;
}
const rlim_t worker_descriptors = 2 * active_games;
if(open_connections > maximum - worker_descriptors
- RESERVED_DESCRIPTORS)
{
return false;
}
const rlim_t desired = open_connections + worker_descriptors
+ RESERVED_DESCRIPTORS;
if(desired > limits.rlim_max)
{
return false;
}
limits.rlim_cur = desired;
return ::setrlimit(RLIMIT_NOFILE, &limits) == 0;
}
} // namespace
int main(int argc, char* argv[])
{
if(argc > 1 && std::string(argv[1]) == "--engine")
{
return nethack_mcp::runEngineWorker(argc, argv);
}
nethack_mcp::ServerConfig config;
std::string parse_error;
const ParseResult parsed = parseOptions(argc, argv, config, parse_error);
if(parsed == ParseResult::HELP)
{
return 0;
}
if(parsed == ParseResult::ERROR)
{
if(parse_error.empty())
{
parse_error = "invalid or incomplete command line options";
}
std::fprintf(stderr, "%s; use --help\n", parse_error.c_str());
return 2;
}
if(!configureDescriptorLimit(config))
{
std::fputs("could not apply the configured open-connection limit\n",
stderr);
return 1;
}
const std::filesystem::path runtime_source = config.runtime_directory;
auto manager_result = nethack_mcp::GameManager::create(
std::move(config), runtime_source);
if(!manager_result)
{
std::fprintf(stderr, "could not initialize game manager: %s\n",
mw::errorMsg(manager_result.error()).c_str());
return 1;
}
std::unique_ptr<nethack_mcp::GameManager> manager =
std::move(*manager_result);
nethack_mcp::McpServer mcp(*manager);
nethack_mcp::GameHttpServer http_server(*manager, mcp);
std::string error;
if(!http_server.startServer(error))
{
std::fprintf(stderr, "%s\n", error.c_str());
manager->shutdown();
return 1;
}
std::fprintf(stderr, "MCP: %smcp\n",
manager->publicBaseUrl().c_str());
std::fprintf(stderr, "Spectators: %s\n",
manager->publicBaseUrl().c_str());
std::signal(SIGINT, requestStop);
std::signal(SIGTERM, requestStop);
while(!stop_requested && http_server.running())
{
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
const bool interrupted = stop_requested != 0;
http_server.stopServer();
manager->shutdown();
return interrupted ? 0 : 1;
}