yams/tests/unit/daemon/asio_connection_pool_catch2_test.cpp
2026-07-26 20:42:21 -04:00

998 lines
34 KiB
C++

// AsioConnectionPool unit tests for stale socket detection
#include <catch2/catch_test_macros.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/local/stream_protocol.hpp>
#include <boost/asio/use_future.hpp>
#include <atomic>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <future>
#include <thread>
#include "../../common/test_helpers_catch2.h"
#include <yams/compat/unistd.h>
#include <yams/daemon/client/asio_connection_pool.h>
#include <yams/daemon/client/global_io_context.h>
#include <yams/daemon/client/ipc_failure.h>
#ifndef _WIN32
#include <sys/socket.h>
#include <sys/un.h>
#endif
using namespace yams::daemon;
using namespace std::chrono_literals;
namespace fs = std::filesystem;
namespace {
fs::path makeTempRuntimeDir(const std::string& name) {
auto base = fs::temp_directory_path();
#ifndef _WIN32
constexpr std::size_t maxUnixPath = sizeof(sockaddr_un::sun_path) - 1;
auto candidate = base / "yams-connection-pool-tests" / name / "ipc.sock";
if (candidate.native().size() >= maxUnixPath) {
base = fs::path("/tmp");
}
#endif
auto dir = base / "yams-connection-pool-tests" / name;
std::error_code ec;
fs::create_directories(dir, ec);
return dir;
}
std::string randomSuffix() {
return std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
}
} // namespace
TEST_CASE("AsioConnection socket close waits for the socket strand",
"[daemon][connection-pool][strand][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
boost::asio::io_context io;
TransportOptions opts;
opts.executor = io.get_executor();
auto conn = std::make_shared<AsioConnection>(opts);
conn->socket = std::make_unique<AsioConnection::socket_t>(conn->strand);
boost::system::error_code ec;
conn->socket->open(boost::asio::local::stream_protocol(), ec);
REQUIRE_FALSE(ec);
std::promise<void> blockerEntered;
std::promise<void> releaseBlocker;
auto releaseFuture = releaseBlocker.get_future();
boost::asio::post(conn->strand, [&] {
blockerEntered.set_value();
releaseFuture.wait();
});
std::thread ioThread([&] { io.run(); });
REQUIRE(blockerEntered.get_future().wait_for(1s) == std::future_status::ready);
std::atomic<bool> closeReturned{false};
std::thread closeThread([&] {
conn->close();
closeReturned.store(true, std::memory_order_release);
});
std::this_thread::sleep_for(650ms);
CHECK_FALSE(closeReturned.load(std::memory_order_acquire));
releaseBlocker.set_value();
closeThread.join();
io.stop();
ioThread.join();
CHECK(closeReturned.load(std::memory_order_acquire));
CHECK_FALSE(conn->socket);
}
TEST_CASE("AsioConnection close does not hang when its executor is already stopped",
"[daemon][connection-pool][strand][shutdown][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
boost::asio::io_context io;
TransportOptions opts;
opts.executor = io.get_executor();
auto conn = std::make_shared<AsioConnection>(opts);
conn->socket = std::make_unique<AsioConnection::socket_t>(conn->strand);
boost::system::error_code ec;
conn->socket->open(boost::asio::local::stream_protocol(), ec);
REQUIRE_FALSE(ec);
io.stop();
std::atomic<bool> closeReturned{false};
std::thread closeThread([&] {
conn->close();
closeReturned.store(true, std::memory_order_release);
});
if (!yams::test::wait_for_condition(
1s, 10ms, [&] { return closeReturned.load(std::memory_order_acquire); })) {
// Keep the regression test from stranding a blocked helper thread if the bug returns.
io.restart();
io.run_one();
closeThread.join();
FAIL("AsioConnection::close blocked on a stopped executor");
}
closeThread.join();
CHECK_FALSE(conn->socket);
}
TEST_CASE("AsioConnectionPool shutdown waits for in-progress connection creation",
"[daemon][connection-pool][shutdown][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("shutdown-create-" + randomSuffix());
auto socketPath = runtimeDir / "missing.sock";
std::error_code ec;
fs::remove(socketPath, ec);
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 2s;
opts.poolEnabled = false;
auto pool = std::make_shared<AsioConnectionPool>(opts, false);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(yams::test::wait_for_condition(2s, 10ms,
[&] { return pool->testing_pending_creates() > 0; }));
pool->shutdown(500ms);
REQUIRE(fut.wait_for(1s) == std::future_status::ready);
auto result = fut.get();
REQUIRE_FALSE(result.has_value());
CHECK(result.error().code == yams::ErrorCode::SystemShutdown);
}
TEST_CASE("AsioConnectionPool drops closed socket on reuse", "[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("stale-socket-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::promise<void> accepted1;
std::promise<void> accepted2;
std::promise<void> close1;
std::atomic<bool> stop{false};
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock1(server_io);
acceptor.accept(sock1);
accepted1.set_value();
close1.get_future().wait();
sock1.close(bec);
boost::asio::local::stream_protocol::socket sock2(server_io);
acceptor.accept(sock2);
accepted2.set_value();
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock2.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut1 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut1.wait_for(1s) == std::future_status::ready);
auto conn1_res = fut1.get();
REQUIRE(conn1_res);
auto conn1 = conn1_res.value();
REQUIRE(conn1);
REQUIRE(accepted1.get_future().wait_for(1s) == std::future_status::ready);
pool->release(conn1);
close1.set_value();
std::this_thread::sleep_for(50ms);
auto fut2 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut2.wait_for(1s) == std::future_status::ready);
auto conn2_res = fut2.get();
REQUIRE(conn2_res);
auto conn2 = conn2_res.value();
REQUIRE(conn2);
REQUIRE(accepted2.get_future().wait_for(1s) == std::future_status::ready);
REQUIRE(conn1.get() != conn2.get());
pool->release(conn2);
stop.store(true);
server_thread.join();
boost::system::error_code bec;
acceptor.close(bec);
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool handles server idle close", "[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// This test simulates the daemon's idle timeout behavior:
// Server closes connection after short idle period, client should detect this
// and create a new connection on next acquire()
auto runtimeDir = makeTempRuntimeDir("idle-close-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<int> connections_accepted{0};
std::promise<void> first_accepted;
std::promise<void> first_closed;
std::promise<void> second_accepted;
std::atomic<bool> stop{false};
// Server that closes connections after 100ms of idle
std::thread server_thread([&] {
boost::system::error_code bec;
// Accept first connection
boost::asio::local::stream_protocol::socket sock1(server_io);
acceptor.accept(sock1, bec);
if (!bec) {
connections_accepted.fetch_add(1);
first_accepted.set_value();
// Simulate server idle timeout: close after 100ms
std::this_thread::sleep_for(100ms);
sock1.close(bec);
first_closed.set_value();
}
// Accept second connection
if (!stop.load()) {
boost::asio::local::stream_protocol::socket sock2(server_io);
acceptor.accept(sock2, bec);
if (!bec) {
connections_accepted.fetch_add(1);
second_accepted.set_value();
// Keep socket open until test completes
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock2.close(bec);
}
}
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 50ms; // Very short timeout
opts.headerTimeout = 200ms; // Short header timeout
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
// First connection
auto fut1 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut1.wait_for(2s) == std::future_status::ready);
auto conn1_res = fut1.get();
REQUIRE(conn1_res);
auto conn1 = conn1_res.value();
REQUIRE(conn1);
REQUIRE(first_accepted.get_future().wait_for(1s) == std::future_status::ready);
REQUIRE(connections_accepted.load() == 1);
pool->release(conn1);
// Wait until the server has actually closed the idle socket before reusing the pool.
REQUIRE(first_closed.get_future().wait_for(1s) == std::future_status::ready);
// Second connection - should detect stale and create new
auto fut2 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut2.wait_for(2s) == std::future_status::ready);
auto conn2_res = fut2.get();
REQUIRE(conn2_res);
auto conn2 = conn2_res.value();
REQUIRE(conn2);
REQUIRE(second_accepted.get_future().wait_for(1s) == std::future_status::ready);
// Should be a different connection (stale one was detected and replaced)
REQUIRE(connections_accepted.load() == 2);
pool->release(conn2);
stop.store(true);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool handles EOF during read", "[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// This test simulates the case where server closes connection
// while client is in the middle of a read loop
auto runtimeDir = makeTempRuntimeDir("eof-read-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::promise<void> close_now;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock);
connected.set_value();
// Wait for signal to close
close_now.get_future().wait();
// Close without sending anything - simulates sudden disconnect
sock.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 50ms;
opts.headerTimeout = 100ms;
opts.bodyTimeout = 100ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(1s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
auto conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
// Start the read loop on the connection
auto readLoopFut =
boost::asio::co_spawn(GlobalIOContext::global_executor(),
pool->ensure_read_loop_started(conn), boost::asio::use_future);
// Wait for read loop to start
std::this_thread::sleep_for(50ms);
// Signal server to close the connection
close_now.set_value();
// Wait a bit for the read loop to detect EOF
std::this_thread::sleep_for(200ms);
// Connection should be marked as dead
CHECK_FALSE(conn->alive.load());
pool->release(conn);
stop.store(true);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool handles read timeout", "[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("read-timeout-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock);
connected.set_value();
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 50ms;
opts.headerTimeout = 50ms;
opts.bodyTimeout = 50ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(1s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
auto conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
auto readLoopFut =
boost::asio::co_spawn(GlobalIOContext::global_executor(),
pool->ensure_read_loop_started(conn), boost::asio::use_future);
REQUIRE(readLoopFut.wait_for(1s) == std::future_status::ready);
std::this_thread::sleep_for(200ms);
CHECK_FALSE(conn->alive.load());
pool->release(conn);
stop.store(true);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool shutdown cancels a pending read loop on the socket strand",
"[daemon][connection-pool][unit][tsan]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("shutdown-pending-read-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock, bec);
if (!bec) {
connected.set_value();
while (!stop.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
}
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 5s;
opts.headerTimeout = 5s;
opts.bodyTimeout = 5s;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(1s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
auto conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
auto readLoopFut =
boost::asio::co_spawn(GlobalIOContext::global_executor(),
pool->ensure_read_loop_started(conn), boost::asio::use_future);
REQUIRE(readLoopFut.wait_for(1s) == std::future_status::ready);
std::this_thread::sleep_for(50ms);
REQUIRE(conn->alive.load(std::memory_order_acquire));
AsioConnectionPool::shutdown_all(500ms);
CHECK(pool->is_shutdown());
CHECK_FALSE(conn->alive.load(std::memory_order_acquire));
stop.store(true, std::memory_order_release);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
}
TEST_CASE("Non-shared pool connection stays alive via pool_keepalive",
"[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// This test verifies that when poolEnabled=false (non-shared pool),
// the pool_keepalive reference in the connection prevents the pool
// from being destroyed prematurely.
auto runtimeDir = makeTempRuntimeDir("non-shared-pool-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock);
connected.set_value();
// Keep connection open until test completes
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = false; // Non-shared pool - this is the key setting
// Create pool and acquire connection in a scope that lets pool go out of scope
std::shared_ptr<AsioConnection> conn;
{
auto pool = AsioConnectionPool::get_or_create(opts);
REQUIRE_FALSE(pool->is_shared()); // Verify it's a non-shared pool
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(2s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
// Verify pool_keepalive is set for non-shared pools
REQUIRE(conn->pool_keepalive != nullptr);
// Pool goes out of scope here, but connection should still be alive
// because pool_keepalive holds a reference
}
// Connection should still be alive after pool variable went out of scope
REQUIRE(conn->alive.load());
REQUIRE(conn->socket);
REQUIRE(conn->socket->is_open());
// Clean up
conn->pool_keepalive.reset(); // Release the keepalive reference
conn.reset();
stop.store(true);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
}
TEST_CASE("Shared pool does not set pool_keepalive", "[daemon][connection-pool][unit]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// Shared pools are stored in the global registry, so they don't need
// the keepalive mechanism. Verify pool_keepalive is not set.
auto runtimeDir = makeTempRuntimeDir("shared-pool-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock);
connected.set_value();
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = true; // Shared pool
auto pool = AsioConnectionPool::get_or_create(opts);
REQUIRE(pool->is_shared()); // Verify it's a shared pool
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(2s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
auto conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
// Shared pools should NOT set pool_keepalive (they're in the registry)
REQUIRE(conn->pool_keepalive == nullptr);
pool->release(conn);
stop.store(true);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool retries a transient refused connection",
"[daemon][connection-pool][unit][ipc]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#else
auto runtimeDir = makeTempRuntimeDir("transient-refused-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
int listener = ::socket(AF_UNIX, SOCK_STREAM, 0);
REQUIRE(listener >= 0);
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
const auto socketString = socketPath.string();
REQUIRE(socketString.size() < sizeof(addr.sun_path));
std::strncpy(addr.sun_path, socketString.c_str(), sizeof(addr.sun_path) - 1);
REQUIRE(::bind(listener, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0);
TransportOptions opts;
opts.socketPath = socketPath;
opts.poolEnabled = false;
opts.requestTimeout = 500ms;
auto pool = AsioConnectionPool::get_or_create(opts);
std::promise<void> refused;
std::atomic_flag retryObserved = ATOMIC_FLAG_INIT;
pool->testing_setRetryHook([&] {
if (!retryObserved.test_and_set(std::memory_order_acq_rel)) {
refused.set_value();
}
});
auto future = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
// Start listening only after the pool observes the initial refusal, so the next retry must
// establish the connection.
REQUIRE(refused.get_future().wait_for(1s) == std::future_status::ready);
REQUIRE(::listen(listener, 1) == 0);
REQUIRE(future.wait_for(1s) == std::future_status::ready);
auto connection = future.get();
REQUIRE(connection);
const int accepted = ::accept(listener, nullptr, nullptr);
REQUIRE(accepted >= 0);
pool->release(connection.value());
REQUIRE(::close(accepted) == 0);
REQUIRE(::close(listener) == 0);
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
#endif
}
TEST_CASE("AsioConnectionPool does not double-wrap IPC failure prefixes",
"[daemon][connection-pool][unit][ipc]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("ipc-prefix-refused-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
#ifndef _WIN32
// Create a stale AF_UNIX socket path (bound but not listening) to induce ECONNREFUSED.
int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
REQUIRE(fd >= 0);
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
const auto sockStr = socketPath.string();
REQUIRE(sockStr.size() < sizeof(addr.sun_path));
std::strncpy(addr.sun_path, sockStr.c_str(), sizeof(addr.sun_path) - 1);
REQUIRE(::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0);
REQUIRE(::close(fd) == 0);
#endif
TransportOptions opts;
opts.socketPath = socketPath;
opts.poolEnabled = false;
opts.requestTimeout = 500ms;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(2s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE_FALSE(conn_res);
const auto& msg = conn_res.error().message;
REQUIRE(msg.rfind(std::string(kIpcFailurePrefix), 0) == 0);
REQUIRE(parseIpcFailureKind(msg).has_value());
CHECK(msg.find(std::string(kIpcFailurePrefix), 1) == std::string::npos);
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool shutdown short-circuits in one-shot CLI mode",
"[daemon][connection-pool][unit][lifecycle]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
auto runtimeDir = makeTempRuntimeDir("one-shot-shutdown-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::promise<void> connected;
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock, bec);
if (!bec) {
connected.set_value();
while (!stop.load(std::memory_order_acquire)) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
}
});
yams::test::ScopedEnvVar oneShot("YAMS_CLI_ONE_SHOT", std::string("1"));
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
auto fut = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut.wait_for(2s) == std::future_status::ready);
auto conn_res = fut.get();
REQUIRE(conn_res);
auto conn = conn_res.value();
REQUIRE(conn);
REQUIRE(connected.get_future().wait_for(1s) == std::future_status::ready);
// Inject a valid but unresolved future to emulate a stuck read loop.
std::promise<void> pendingReadLoop;
conn->read_loop_future = pendingReadLoop.get_future();
pool->release(conn);
const auto start = std::chrono::steady_clock::now();
pool->shutdown(500ms);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
CHECK(elapsed < 450ms);
pendingReadLoop.set_value();
stop.store(true, std::memory_order_release);
boost::system::error_code bec;
acceptor.close(bec);
server_thread.join();
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
// --- Health check TTL tests ---
TEST_CASE("AsioConnectionPool reuses connection on rapid re-acquire",
"[daemon][connection-pool][health-ttl]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// Validate that acquire → release → immediate re-acquire returns the same connection
// (the health check cache should allow reuse without a syscall).
auto runtimeDir = makeTempRuntimeDir("health-ttl-reuse-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::atomic<bool> stop{false};
std::thread server_thread([&] {
boost::system::error_code bec;
boost::asio::local::stream_protocol::socket sock(server_io);
acceptor.accept(sock);
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
// First acquire
auto fut1 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut1.wait_for(1s) == std::future_status::ready);
auto conn1_res = fut1.get();
REQUIRE(conn1_res);
auto conn1 = conn1_res.value();
REQUIRE(conn1);
auto conn1_ptr = conn1.get();
pool->release(conn1);
// Immediate re-acquire — should get same connection back
auto fut2 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut2.wait_for(1s) == std::future_status::ready);
auto conn2_res = fut2.get();
REQUIRE(conn2_res);
auto conn2 = conn2_res.value();
REQUIRE(conn2);
CHECK(conn2.get() == conn1_ptr);
pool->release(conn2);
stop.store(true);
server_thread.join();
boost::system::error_code bec;
acceptor.close(bec);
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}
TEST_CASE("AsioConnectionPool detects dead socket after release",
"[daemon][connection-pool][health-ttl]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
// Validate: acquire → release → server closes → re-acquire detects the dead socket.
// The TTL is reset on release(), so the next acquire forces a fresh health check.
auto runtimeDir = makeTempRuntimeDir("health-ttl-dead-" + randomSuffix());
auto socketPath = runtimeDir / "ipc.sock";
std::error_code ec;
fs::remove(socketPath, ec);
boost::asio::io_context server_io;
boost::asio::local::stream_protocol::acceptor acceptor(
server_io, boost::asio::local::stream_protocol::endpoint(socketPath.string()));
std::promise<void> accepted1;
std::promise<void> accepted2;
std::promise<void> close1;
std::atomic<bool> stop{false};
std::thread server_thread([&] {
boost::system::error_code bec;
// Accept first connection
boost::asio::local::stream_protocol::socket sock1(server_io);
acceptor.accept(sock1);
accepted1.set_value();
close1.get_future().wait();
sock1.close(bec);
// Accept second connection (after first is closed)
boost::asio::local::stream_protocol::socket sock2(server_io);
acceptor.accept(sock2);
accepted2.set_value();
while (!stop.load()) {
std::this_thread::sleep_for(10ms);
}
sock2.close(bec);
});
TransportOptions opts;
opts.socketPath = socketPath;
opts.requestTimeout = 500ms;
opts.poolEnabled = true;
auto pool = AsioConnectionPool::get_or_create(opts);
// Acquire and release
auto fut1 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut1.wait_for(1s) == std::future_status::ready);
auto conn1_res = fut1.get();
REQUIRE(conn1_res);
auto conn1 = conn1_res.value();
REQUIRE(accepted1.get_future().wait_for(1s) == std::future_status::ready);
pool->release(conn1);
// Server closes the connection
close1.set_value();
std::this_thread::sleep_for(50ms); // Let the close propagate
// Re-acquire — should detect dead socket and create a new connection
auto fut2 = boost::asio::co_spawn(GlobalIOContext::global_executor(), pool->acquire(),
boost::asio::use_future);
REQUIRE(fut2.wait_for(1s) == std::future_status::ready);
auto conn2_res = fut2.get();
REQUIRE(conn2_res);
auto conn2 = conn2_res.value();
REQUIRE(accepted2.get_future().wait_for(1s) == std::future_status::ready);
CHECK(conn1.get() != conn2.get()); // Must be a different connection
pool->release(conn2);
stop.store(true);
server_thread.join();
boost::system::error_code bec;
acceptor.close(bec);
fs::remove(socketPath, ec);
AsioConnectionPool::shutdown_all(100ms);
}