// AsioConnectionPool unit tests for stale socket detection // pi-lens-ignore: fatal error #include #include #include #include #include #include #include #include #include #include #include "../../common/test_helpers_catch2.h" #include #include #include #include #ifndef _WIN32 #include #include #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("ConnectionRegistry shutdown closes idle sockets without terminal cancellation", "[daemon][connection-pool][shutdown][unit]") { boost::asio::io_context io; auto workGuard = boost::asio::make_work_guard(io); std::thread ioThread([&] { io.run(); }); TransportOptions opts; opts.executor = io.get_executor(); auto conn = std::make_shared(opts); conn->socket = std::make_unique(conn->strand); boost::system::error_code ec; conn->socket->open(boost::asio::local::stream_protocol(), ec); REQUIRE_FALSE(ec); std::atomic terminalCancellation{false}; conn->cancellation_slot().assign([&](boost::asio::cancellation_type) { terminalCancellation.store(true, std::memory_order_release); }); ConnectionRegistry::instance().add(conn); ConnectionRegistry::instance().closeAll(); CHECK_FALSE(terminalCancellation.load(std::memory_order_acquire)); CHECK_FALSE(conn->socket); workGuard.reset(); io.stop(); ioThread.join(); } 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(opts); conn->socket = std::make_unique(conn->strand); boost::system::error_code ec; conn->socket->open(boost::asio::local::stream_protocol(), ec); REQUIRE_FALSE(ec); std::promise blockerEntered; std::promise 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 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(opts); conn->socket = std::make_unique(conn->strand); boost::system::error_code ec; conn->socket->open(boost::asio::local::stream_protocol(), ec); REQUIRE_FALSE(ec); io.stop(); std::atomic 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("AsioConnection bypasses a blocked strand during global teardown", "[daemon][connection-pool][strand][shutdown][unit]") { #ifdef _WIN32 SKIP("Unix domain socket tests skipped on Windows"); #endif TransportOptions opts; auto conn = std::make_shared(opts); conn->socket = std::make_unique(conn->strand); boost::system::error_code ec; conn->socket->open(boost::asio::local::stream_protocol(), ec); REQUIRE_FALSE(ec); std::promise blockerEntered; std::promise releaseBlocker; std::promise blockerExited; auto releaseFuture = releaseBlocker.get_future(); boost::asio::post(conn->strand, [&] { blockerEntered.set_value(); releaseFuture.wait(); blockerExited.set_value(); }); REQUIRE(blockerEntered.get_future().wait_for(1s) == std::future_status::ready); GlobalIOContext::testing_set_destroyed(true); auto closeFuture = std::async(std::launch::async, [&] { conn->close(); }); const bool returnedDuringTeardown = closeFuture.wait_for(100ms) == std::future_status::ready; GlobalIOContext::testing_set_destroyed(false); releaseBlocker.set_value(); // The strand task holds a reference to releaseFuture; wait until it actually finishes so the // promises below are not destroyed while the io thread is still inside wait(). REQUIRE(blockerExited.get_future().wait_for(1s) == std::future_status::ready); closeFuture.wait(); CHECK(returnedDuringTeardown); 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(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 accepted1; std::promise accepted2; std::promise close1; std::atomic 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 connections_accepted{0}; std::promise first_accepted; std::promise first_closed; std::promise second_accepted; std::atomic 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 stop{false}; std::promise connected; std::promise 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 stop{false}; std::promise 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 stop{false}; std::promise 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 stop{false}; std::promise 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 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 stop{false}; std::promise 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(&addr), sizeof(addr)) == 0); TransportOptions opts; opts.socketPath = socketPath; opts.poolEnabled = false; opts.requestTimeout = 500ms; auto pool = AsioConnectionPool::get_or_create(opts); std::promise 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(&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 stop{false}; std::promise 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 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::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 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 accepted1; std::promise accepted2; std::promise close1; std::atomic 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); }