Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/concepts/server-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,17 @@ cmake --build out/server-construction --target check-server-plugins --parallel 2
`check-server-plugins` exercises the real factory and assembly of each of the seven enabled server libraries, checks the retained source include paths, verifies disabled update admission, and loads each actual shared library to inspect both exported entry symbols. Each role runs a minimal core-only export host before its separate real-factory host. This process separation prevents the host and shared library from registering the same statically linked AFProto descriptors twice. A counted substitute through the real Login assembly checks destruction of rejected candidates, preservation of the original registration and a successful retry. `check-core` includes these focused checks in application builds. A separate shared-library fixture runs the real Login entry and exit through the manager loader with a controlled module. The seven business libraries receive export inspection and direct construction checks, without business lifecycle calls. These checks do not start network services or establish deployment readiness.

`node --test tests/ci/server-construction.test.mjs` checks configure-only compatibility forwarding and construction policy without compiling native code. Simulate has source-policy coverage only; its unsupported PhysX 3/MessagePack integration is outside these native targets.

## Verify a local launcher composition

On macOS and Linux, `check-server-launcher` starts the real `app` executable with the actual Utility, Bus, Net and Login libraries. It creates a private runtime, supplies a loopback-only ephemeral endpoint for Bus identity `1.1.32.1`, waits for the Login listener, connects one TCP peer, sends the owned process `SIGTERM`, and checks server EOF, a refused fresh connection and exit code 0. The peer replies to the server's FIN by closing its own send side before waiting for process exit. A raw peer that stays open can make the transport wait for its existing graceful-disconnect timeout; that wait is distinct from the helper's five-second cooperative shutdown bound. The helper has bounded startup/shutdown waits and reaps only its own child. Runtime configuration and logs remain under the build tree; a new directory is used for each run.

```sh
cmake --build out/server-construction --target check-server-launcher --parallel 1
```

Enable `ARK_BUILD_PLUGIN_UTILITY`, `ARK_BUILD_PLUGIN_BUS` and `ARK_BUILD_PLUGIN_NET` when configuring the application build. The test writes its own minimal manifest instead of modifying the shipped role presets. The Bus schema still requires a `registry` table; this local composition does not load Consul, register a service or perform discovery. Windows retains the construction tests above; this process/signal controller is enabled only on Unix.

This is launcher and transport acceptance. Login currently starts a listener without registering account-login handlers. Proxy's server-selection and message-forwarding callbacks are incomplete. The shipped Router manifest does not select the Router role library, and the World preset leaves its plugin paths/configuration empty; use an application-owned, reviewed manifest before treating those presets as runnable deployments. No authentication, guest/session flow, cluster routing, reconnect recovery or persistence is established by a successful connection.

For a bounded existing business operation, enable examples and Kernel/Utility and run `check-typed-examples`. Its real EntityOperations library uses the checked-in manifest and metadata to create and inspect player data/tables/custom values, then destroys entities during shutdown. That in-process business demonstration and the separate Login transport composition verify different boundaries; they do not form an implemented network game-login flow.
14 changes: 14 additions & 0 deletions docs/concepts/server-architecture_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,17 @@ cmake --build out/server-construction --target check-server-plugins --parallel 2
`check-server-plugins` 对七个启用的服务动态库分别检查真实工厂与装配、原有源码包含路径和关闭更新的注册方式,并加载实际动态库检查两个导出入口符号。每个角色先在仅链接核心的最小进程中检查导出,再在独立进程中检查真实工厂,避免宿主与动态库在同一进程内重复注册静态链接的 AFProto 描述符。真实 Login 装配另以计数替身检查被拒绝候选对象的析构、原有注册的保留以及重试成功。应用构建中的 `check-core` 包含这些定向检查。另一个动态库夹具通过管理器加载器运行真实 Login 入口与退出函数,并使用受控模块。七个业务动态库只检查导出与直接创建,不调用业务生命周期。这些检查不启动网络服务,也不代表部署就绪。

`node --test tests/ci/server-construction.test.mjs` 在不编译原生代码的情况下检查兼容转发映射与创建约束。Simulate 只参与源码策略检查,其未受支持的 PhysX 3/MessagePack 集成不在这些原生目标中。

## 验证本地启动器组合

在 macOS 和 Linux 上,`check-server-launcher` 使用真实 `app` 可执行文件以及 Utility、Bus、Net 和 Login 动态库。测试创建独立运行目录,为 Bus 身份 `1.1.32.1` 配置临时回环端口,等待 Login 监听器就绪后建立一个 TCP 连接,再向测试创建的进程发送 `SIGTERM`。测试检查服务器 EOF、新连接被拒绝,以及进程以退出码 0 结束。客户端收到服务器的 FIN 后,先关闭自身发送侧,再等待进程退出。原始 TCP 客户端若一直保持连接,传输层可能等待现有的优雅断开超时;此等待与辅助程序五秒的配合退出上限不同。启动和退出等待均有上限,辅助程序只回收自己创建的子进程。配置和日志保留在构建目录中,每次运行使用新目录。

```sh
cmake --build out/server-construction --target check-server-launcher --parallel 1
```

配置应用构建时需启用 `ARK_BUILD_PLUGIN_UTILITY`、`ARK_BUILD_PLUGIN_BUS` 和 `ARK_BUILD_PLUGIN_NET`。测试生成最小清单,不修改随仓库提供的角色预设。Bus 配置格式仍要求 `registry` 表;此本地组合不加载 Consul,也不注册服务或执行发现。Windows 保留前述构造测试;进程和信号控制测试仅在 Unix 上启用。

此测试验收启动器和传输边界。Login 当前只创建监听器,没有注册账号登录处理器。Proxy 的服务器选择和消息转发回调尚不完整。Router 清单未选择 Router 角色动态库,World 预设的插件路径和配置为空;将这些预设用于部署前,需要应用提供经过审阅的清单。连接成功不能证明认证、访客或会话流程、集群路由、重连恢复或持久化已经可用。

验证现有的有限业务操作时,可启用示例及 Kernel、Utility 后运行 `check-typed-examples`。其中真实 EntityOperations 动态库使用仓库内的清单和元数据,创建并检查玩家数据、表格和自定义值,再于退出时销毁实体。此进程内业务示例与独立的 Login 传输组合分别验证不同边界,两者尚未组成网络游戏登录流程。
18 changes: 18 additions & 0 deletions tests/server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,21 @@ add_custom_target(check-server-entry
COMMAND $<TARGET_FILE:ark_server_entry_tests> "$<TARGET_FILE_DIR:ark_server_entry_probe>/" "ark_server_entry_probe"
DEPENDS ark_server_entry_tests ark_server_entry_probe USES_TERMINAL VERBATIM)
add_dependencies(check-server-plugins check-server-entry)

# Exercise the actual launcher and supported local composition, without a
# registry service or business protocol doubles. This controller owns its child.
if(UNIX AND TARGET app AND TARGET ark_utility_plugin AND TARGET ark_bus_plugin AND TARGET ark_net_plugin)
add_executable(ark_server_launcher_tests launcher.cpp)
set_property(TARGET ark_server_launcher_tests PROPERTY LINK_LIBRARIES "")
add_custom_target(check-server-launcher
COMMAND ${CMAKE_COMMAND}
"-DPROGRAM=$<TARGET_FILE:ark_server_launcher_tests>"
"-DLAUNCHER=$<TARGET_FILE:app>"
"-DPLUGIN_DIR=$<TARGET_FILE_DIR:ark_login_plugin>"
"-DSOURCE_ROOT=${PROJECT_SOURCE_DIR}"
"-DRUNTIME_ROOT=${CMAKE_CURRENT_BINARY_DIR}/launcher runtime"
-P "${PROJECT_SOURCE_DIR}/tests/server/launcher.cmake"
DEPENDS ark_server_launcher_tests app ark_utility_plugin ark_bus_plugin ark_net_plugin ark_login_plugin
USES_TERMINAL VERBATIM)
add_dependencies(check-server-plugins check-server-launcher)
endif()
15 changes: 15 additions & 0 deletions tests/server/launcher.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) 2026 OpenArkStudio. SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.20)
foreach(argument PROGRAM LAUNCHER PLUGIN_DIR SOURCE_ROOT RUNTIME_ROOT)
if(NOT DEFINED ${argument})
message(FATAL_ERROR "${argument} is required")
endif()
endforeach()
file(MAKE_DIRECTORY "${RUNTIME_ROOT}")
string(RANDOM LENGTH 16 ALPHABET 0123456789abcdef suffix)
execute_process(COMMAND "${PROGRAM}" "${LAUNCHER}" "${PLUGIN_DIR}" "${SOURCE_ROOT}" "${RUNTIME_ROOT}/${suffix}"
RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE errors TIMEOUT 20)
if(NOT "${result}" STREQUAL "0")
message(FATAL_ERROR "Server launcher regression failed: ${result}\n${output}\n${errors}\nRuntime: ${RUNTIME_ROOT}/${suffix}")
endif()
message(STATUS "${output}")
216 changes: 216 additions & 0 deletions tests/server/launcher.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright (c) 2026 OpenArkStudio. SPDX-License-Identifier: Apache-2.0
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <fcntl.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <sys/socket.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>

namespace {
using namespace std::chrono_literals;

void require(bool condition, const char* message) {
if (!condition)
{
throw std::runtime_error(message);
}
}

struct socket_owner
{
int value = ::socket(AF_INET, SOCK_STREAM, 0);

~socket_owner() {
if (value >= 0)
{
::close(value);
}
}
};

struct child_owner
{
pid_t pid = -1;

~child_owner() {
if (pid > 0)
{
// Only this helper's unreaped child is eligible for cleanup.
::kill(pid, SIGKILL);
while (::waitpid(pid, nullptr, 0) < 0 && errno == EINTR)
{
}
}
}
};

sockaddr_in address(unsigned short port) {
sockaddr_in result{};
result.sin_family = AF_INET;
result.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
result.sin_port = htons(port);
return result;
}

int connect_to(int socket, unsigned short port) {
require(socket >= 0, "socket creation failed");
require(::fcntl(socket, F_SETFL, O_NONBLOCK) == 0, "nonblocking socket setup failed");
const auto endpoint = address(port);
if (::connect(socket, reinterpret_cast<const sockaddr*>(&endpoint), sizeof(endpoint)) == 0)
{
return 0;
}
if (errno != EINPROGRESS)
{
return errno;
}
pollfd pending{socket, POLLOUT, 0};
const auto ready = ::poll(&pending, 1, 100);
require(ready >= 0, "connection poll failed");
if (ready == 0)
{
return ETIMEDOUT;
}
int error = 0;
socklen_t size = sizeof(error);
require(::getsockopt(socket, SOL_SOCKET, SO_ERROR, &error, &size) == 0, "connection result failed");
return error;
}

std::string read_log(const std::filesystem::path& path) {
std::ifstream input(path);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
} // namespace

int main(int argc, char** argv) {
try
{
require(argc == 5, "Expected launcher, plugin directory, source root and fresh runtime directory");
const auto program = std::filesystem::absolute(argv[1]).string();
const auto plugins = std::filesystem::absolute(argv[2]).string();
const auto source = std::filesystem::absolute(argv[3]);
const auto runtime = std::filesystem::absolute(argv[4]);
require(std::filesystem::create_directory(runtime), "Runtime directory must be fresh");
std::filesystem::permissions(runtime, std::filesystem::perms::owner_all);
const auto log_path = runtime / "output.log";
unsigned short port = 0;
{
socket_owner reservation;
const auto endpoint = address(0);
require(reservation.value >= 0 &&
::bind(reservation.value, reinterpret_cast<const sockaddr*>(&endpoint), sizeof(endpoint)) == 0,
"Ephemeral loopback port allocation failed");
sockaddr_in allocated{};
socklen_t size = sizeof(allocated);
require(::getsockname(reservation.value, reinterpret_cast<sockaddr*>(&allocated), &size) == 0,
"Ephemeral loopback port lookup failed");
port = ntohs(allocated.sin_port);
}
// The launcher owns the bind. A collision after releasing the reservation
// is a test failure, never permission to stop an unrelated listener.
std::ofstream manifest(runtime / "application.toml");
manifest << "[application]\nplugin_library_dir = '" << plugins << "/'\nplugin_config_dir = './'\n"
<< "[[plugins]]\nname = 'ark_utility_plugin'\nconfig = ''\n"
<< "[[plugins]]\nname = 'ark_bus_plugin'\nconfig = 'bus.toml'\n"
<< "[[plugins]]\nname = 'ark_net_plugin'\nconfig = ''\n"
<< "[[plugins]]\nname = 'ark_login_plugin'\nconfig = ''\n";
manifest.close();
std::ofstream bus(runtime / "bus.toml");
bus << "[bus]\nrelations = []\n[[bus.applications]]\nname = 'login'\ntype = 32\n"
<< "[[bus.processes]]\nbus_id = '1.1.32.1'\nserver_endpoint = 'tcp://127.0.0.1:" << port
<< "'\nintranet_endpoint = ''\nmax_connections = 8\nthreads = 1\n"
<< "[bus.registry]\nhost = '127.0.0.1'\nport = 0\nservice_name = 'unused'\n"
<< "check_interval = '10s'\ncheck_timeout = '3s'\n";
bus.close();
const auto app_conf = "--app_conf=" + (runtime / "application.toml").string();
const auto log_conf = "--log_conf=" + (source / "config/log.conf").string();
child_owner child;
child.pid = ::fork();
require(child.pid >= 0, "fork failed");
if (child.pid == 0)
{
if (::chdir(runtime.c_str()) != 0)
{
std::_Exit(125);
}
const auto output = ::open(log_path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600);
if (output < 0 || ::dup2(output, STDOUT_FILENO) < 0 || ::dup2(output, STDERR_FILENO) < 0)
{
std::_Exit(125);
}
::close(output);
::execl(program.c_str(), program.c_str(), "--busid=1.1.32.1", "--name=login", app_conf.c_str(),
log_conf.c_str(), static_cast<char*>(nullptr));
std::_Exit(126);
}
const auto startup_deadline = std::chrono::steady_clock::now() + 10s;
for (;;)
{
const auto waited = ::waitpid(child.pid, nullptr, WNOHANG);
if (waited == child.pid)
{
child.pid = -1;
throw std::runtime_error("Launcher exited before readiness");
}
require(waited == 0, "Launcher startup wait failed");
if (read_log(log_path).find("Start net server successful") != std::string::npos)
{
break;
}
require(std::chrono::steady_clock::now() < startup_deadline, "Launcher startup timed out");
std::this_thread::sleep_for(10ms);
}
socket_owner peer;
require(connect_to(peer.value, port) == 0, "Real Login loopback listener did not accept a connection");
require(::kill(child.pid, SIGTERM) == 0, "Owned launcher signal failed");
const auto stop_deadline = std::chrono::steady_clock::now() + 5s;
pollfd closing{peer.value, POLLIN, 0};
require(::poll(&closing, 1, 5000) > 0, "Stopping launcher did not close its send side");
char byte = 0;
const auto received = ::recv(peer.value, &byte, 1, 0);
require(received == 0 || (received < 0 && errno == ECONNRESET), "Stopped launcher retained its connected peer");
// Cooperate with the transport's graceful half-close. Waiting for the
// process before replying to its FIN would wait for its native deadline.
::shutdown(peer.value, SHUT_WR);
int status = 0;
for (;;)
{
const auto waited = ::waitpid(child.pid, &status, WNOHANG);
require(waited >= 0, "Launcher wait failed");
if (waited == child.pid)
{
child.pid = -1;
break;
}
require(std::chrono::steady_clock::now() < stop_deadline, "Launcher normal stop timed out");
std::this_thread::sleep_for(10ms);
}
require(WIFEXITED(status) && WEXITSTATUS(status) == 0, "Launcher did not exit successfully");
socket_owner fresh;
require(connect_to(fresh.value, port) == ECONNREFUSED, "Stopped launcher retained its listener");
const auto output = read_log(log_path);
require(output.find("ARK Server stop ...") != std::string::npos, "Normal stop diagnostic is missing");
require(output.find("Application startup failed") == std::string::npos &&
output.find("Shutdown failed") == std::string::npos,
"Launcher reported a lifecycle failure");
std::cout << "Actual Utility/Bus/Net/Login DSOs: loopback connection, signal shutdown, peer closure, "
"listener refusal and exit 0 passed; no login business handler claimed\n";
}
catch (const std::exception& error)
{
std::cerr << error.what() << '\n';
return 1;
}
}
Loading