Loading test/CMakeLists.txt +8 −0 Original line number Diff line number Diff line Loading @@ -252,3 +252,11 @@ else() target_link_libraries(certgen_test netplus-static) endif() add_test(NAME certgen_test COMMAND certgen_test) add_executable(idle_reaper_test idle_reaper_test.cpp) if(WIN32) target_link_libraries(idle_reaper_test netplus-static ws2_32) else() target_link_libraries(idle_reaper_test netplus-static) endif() add_test(NAME idle_reaper_test COMMAND idle_reaper_test) test/idle_reaper_test.cpp 0 → 100644 +254 −0 Original line number Diff line number Diff line /******************************************************************************* * Copyright (c) 2026, Jan Koester jan.koester@gmx.net * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Regression test for netplus::event's opt-in idle-connection reaper // (eventapi.h: event(serversockets, timeout, idleTimeoutSeconds)). // // Two scenarios, both driven purely through the public API (real server, // real background event-loop thread, real plain-TCP client socket -- no // internal hooks into epoll.cpp/connection.h): // // 1. idleTimeoutSeconds > 0: a client that connects and then sends nothing // further must have its connection closed by the server once it has // been idle for longer than the configured timeout. Verified by the // client observing EOF (peer closed) on a subsequent recv. // // 2. idleTimeoutSeconds == 0 (the default): a client left idle for the // *same* wall-clock duration must NOT be closed -- the connection must // still be alive and able to complete a real request/response // round-trip afterwards. This is the important negative case: it // proves the feature is opt-in, not just that idle connections *can* // be closed. // // Testability note on the 30s sweep throttle: // reapIdleConnections() (src/event/epoll.cpp) deliberately only actually // scans the connection registry once per kIdleSweepIntervalSeconds (30s) // real seconds, so a busy listener isn't rescanning its whole CONNECTIONS // map on every single event-loop timeout. To keep this test fast and // deterministic instead of needing a real 30+ second sleep, we point it at // a 1-second sweep interval via the NETPLUS_IDLE_SWEEP_INTERVAL_SECONDS // environment variable -- a test-only escape hatch added alongside the // reaper for exactly this purpose (see epoll.cpp/kqueue.cpp's // sweepIntervalSeconds()). It must be set before the first sweep attempt // of the process, so it's set at the very top of main(). #include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <memory> #include <thread> #include <chrono> #include "connection.h" #include "eventapi.h" #include "exception.h" using namespace netplus; namespace { // Minimal test server. Any bytes received trigger a small, recognizable // fixed response -- enough to prove a connection is genuinely alive and // functional end-to-end, without needing a real HTTP parser. class IdleReaperServer : public event { public: IdleReaperServer(std::vector<netplus::socket*> serversockets, int timeout, int idleTimeoutSeconds) : event(serversockets, timeout, idleTimeoutSeconds) { } void RequestEvent(con &curcon, const int tid, ULONG_PTR args) override { static const std::string body = "still-alive\n"; curcon.SendData.append(body.data(), body.size()); curcon.RecvData.clear(); } void ResponseEvent(con &curcon, const int tid, ULONG_PTR args) override {} void ConnectEvent(con &curcon, const int tid, ULONG_PTR args) override {} void DisconnectEvent(con &curcon, const int tid, ULONG_PTR args) override {} void CreateConnection(std::shared_ptr<con> &res) override { res = std::make_shared<con>(this); } }; // Runs `srv`'s event loop on a background thread until `body` returns, // then stops it and joins. Centralizes the event::Running dance (a static // flag shared by every netplus::event instance in the process, so the two // scenarios below must run strictly sequentially, never concurrently). template <typename Fn> bool withRunningServer(IdleReaperServer &srv, Fn body) { event::Running = true; std::thread serverThread([&srv]() { srv.runEventloop(); }); // Give the event loop time to create its epoll instance(s) and start // waiting before we connect. std::this_thread::sleep_for(std::chrono::milliseconds(500)); bool ok = false; try { ok = body(); } catch (const std::exception &e) { std::cerr << " scenario threw exception: " << e.what() << std::endl; ok = false; } catch (...) { std::cerr << " scenario threw unknown exception" << std::endl; ok = false; } event::Running = false; if (serverThread.joinable()) serverThread.join(); return ok; } // Scenario 1: idleTimeoutSeconds > 0 must actually close a genuinely idle // connection. This is the test that would fail (hang / never see EOF) if // the reaper were a no-op, never invoked, or its age comparison were // backwards. bool testIdleConnectionGetsClosed() { std::cout << "[idle_reaper_test] scenario 1: idleTimeoutSeconds=2 closes an idle connection" << std::endl; const int idleTimeoutSeconds = 2; tcp serverSocket("127.0.0.1", 18085, 1024, -1); IdleReaperServer srv({&serverSocket}, /*timeout(ms)=*/200, idleTimeoutSeconds); return withRunningServer(srv, [&]() -> bool { tcp client; client.connect("127.0.0.1", 18085); client.setTimeout(5000); // Deliberately send nothing at all. lasteventime is stamped at // connect time (con's constructor), so the idle clock is already // running -- no data exchange is required to age this connection. // Wait well past: idleTimeoutSeconds (2s) + the (overridden) 1s // sweep throttle + generous scheduling slack, so this isn't a // razor's-edge timing test. std::this_thread::sleep_for(std::chrono::seconds(idleTimeoutSeconds + 4)); buffer recvbuf(64); try { size_t n = client.recvData(recvbuf); std::cerr << " FAIL: expected the server to have closed the idle connection, " "but recvData returned " << n << " bytes instead of EOF" << std::endl; client.close(); return false; } catch (NetException &e) { client.close(); if (e.getErrorType() == NetException::Error) { std::cout << " OK: client observed connection closed by server (" << e.what() << ")" << std::endl; return true; } std::cerr << " FAIL: recvData threw, but not a peer-closed error: " << e.what() << std::endl; return false; } }); } // Scenario 2 (negative case): idleTimeoutSeconds == 0 is the documented // default and must leave a connection that goes equally idle completely // untouched. This is the test that would fail if the reaper ignored the // "0 = disabled" contract (e.g. treated 0 as "no minimum" and reaped // everything, or a copy/paste bug always enabled the sweep regardless of // the configured value). bool testDefaultDisabledLeavesConnectionAlive() { std::cout << "[idle_reaper_test] scenario 2: idleTimeoutSeconds=0 (default) leaves an " "equally idle connection alive" << std::endl; tcp serverSocket("127.0.0.1", 18086, 1024, -1); // idleTimeoutSeconds intentionally 0 -- opt-out / unchanged default behavior. IdleReaperServer srv({&serverSocket}, /*timeout(ms)=*/200, /*idleTimeoutSeconds=*/0); return withRunningServer(srv, [&]() -> bool { tcp client; client.connect("127.0.0.1", 18086); client.setTimeout(5000); // Same idle wait as scenario 1, so this is a genuine apples-to-apples // comparison, not just a shorter/easier wait. std::this_thread::sleep_for(std::chrono::seconds(2 + 4)); // Prove the connection is still alive and fully functional, not // merely "the fd hasn't been closed yet": do a real round-trip. const std::string request = "ping"; bool ok = false; try { buffer sendbuf(request.data(), request.size()); client.sendData(sendbuf); buffer recvbuf(64); size_t n = client.recvData(recvbuf); std::string response(recvbuf.data.buf, n); if (response.find("still-alive") != std::string::npos) { std::cout << " OK: connection still alive, request/response round-trip " "succeeded after idling" << std::endl; ok = true; } else { std::cerr << " FAIL: unexpected response after idling: " << response << std::endl; } } catch (NetException &e) { std::cerr << " FAIL: connection was closed (or otherwise broken) despite " "idleTimeoutSeconds=0: " << e.what() << std::endl; } client.close(); return ok; }); } } // namespace int main() { // Must be set before the very first reapIdleConnections() call in the // process picks up (and caches) the sweep interval -- see the file // banner comment above for why this env var exists at all. setenv("NETPLUS_IDLE_SWEEP_INTERVAL_SECONDS", "1", 1); bool pass1 = testIdleConnectionGetsClosed(); bool pass2 = testDefaultDisabledLeavesConnectionAlive(); if (pass1 && pass2) { std::cout << "idle_reaper_test PASSED" << std::endl; return 0; } std::cerr << "idle_reaper_test FAILED (scenario1=" << pass1 << " scenario2=" << pass2 << ")" << std::endl; return 1; } Loading
test/CMakeLists.txt +8 −0 Original line number Diff line number Diff line Loading @@ -252,3 +252,11 @@ else() target_link_libraries(certgen_test netplus-static) endif() add_test(NAME certgen_test COMMAND certgen_test) add_executable(idle_reaper_test idle_reaper_test.cpp) if(WIN32) target_link_libraries(idle_reaper_test netplus-static ws2_32) else() target_link_libraries(idle_reaper_test netplus-static) endif() add_test(NAME idle_reaper_test COMMAND idle_reaper_test)
test/idle_reaper_test.cpp 0 → 100644 +254 −0 Original line number Diff line number Diff line /******************************************************************************* * Copyright (c) 2026, Jan Koester jan.koester@gmx.net * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ // Regression test for netplus::event's opt-in idle-connection reaper // (eventapi.h: event(serversockets, timeout, idleTimeoutSeconds)). // // Two scenarios, both driven purely through the public API (real server, // real background event-loop thread, real plain-TCP client socket -- no // internal hooks into epoll.cpp/connection.h): // // 1. idleTimeoutSeconds > 0: a client that connects and then sends nothing // further must have its connection closed by the server once it has // been idle for longer than the configured timeout. Verified by the // client observing EOF (peer closed) on a subsequent recv. // // 2. idleTimeoutSeconds == 0 (the default): a client left idle for the // *same* wall-clock duration must NOT be closed -- the connection must // still be alive and able to complete a real request/response // round-trip afterwards. This is the important negative case: it // proves the feature is opt-in, not just that idle connections *can* // be closed. // // Testability note on the 30s sweep throttle: // reapIdleConnections() (src/event/epoll.cpp) deliberately only actually // scans the connection registry once per kIdleSweepIntervalSeconds (30s) // real seconds, so a busy listener isn't rescanning its whole CONNECTIONS // map on every single event-loop timeout. To keep this test fast and // deterministic instead of needing a real 30+ second sleep, we point it at // a 1-second sweep interval via the NETPLUS_IDLE_SWEEP_INTERVAL_SECONDS // environment variable -- a test-only escape hatch added alongside the // reaper for exactly this purpose (see epoll.cpp/kqueue.cpp's // sweepIntervalSeconds()). It must be set before the first sweep attempt // of the process, so it's set at the very top of main(). #include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <memory> #include <thread> #include <chrono> #include "connection.h" #include "eventapi.h" #include "exception.h" using namespace netplus; namespace { // Minimal test server. Any bytes received trigger a small, recognizable // fixed response -- enough to prove a connection is genuinely alive and // functional end-to-end, without needing a real HTTP parser. class IdleReaperServer : public event { public: IdleReaperServer(std::vector<netplus::socket*> serversockets, int timeout, int idleTimeoutSeconds) : event(serversockets, timeout, idleTimeoutSeconds) { } void RequestEvent(con &curcon, const int tid, ULONG_PTR args) override { static const std::string body = "still-alive\n"; curcon.SendData.append(body.data(), body.size()); curcon.RecvData.clear(); } void ResponseEvent(con &curcon, const int tid, ULONG_PTR args) override {} void ConnectEvent(con &curcon, const int tid, ULONG_PTR args) override {} void DisconnectEvent(con &curcon, const int tid, ULONG_PTR args) override {} void CreateConnection(std::shared_ptr<con> &res) override { res = std::make_shared<con>(this); } }; // Runs `srv`'s event loop on a background thread until `body` returns, // then stops it and joins. Centralizes the event::Running dance (a static // flag shared by every netplus::event instance in the process, so the two // scenarios below must run strictly sequentially, never concurrently). template <typename Fn> bool withRunningServer(IdleReaperServer &srv, Fn body) { event::Running = true; std::thread serverThread([&srv]() { srv.runEventloop(); }); // Give the event loop time to create its epoll instance(s) and start // waiting before we connect. std::this_thread::sleep_for(std::chrono::milliseconds(500)); bool ok = false; try { ok = body(); } catch (const std::exception &e) { std::cerr << " scenario threw exception: " << e.what() << std::endl; ok = false; } catch (...) { std::cerr << " scenario threw unknown exception" << std::endl; ok = false; } event::Running = false; if (serverThread.joinable()) serverThread.join(); return ok; } // Scenario 1: idleTimeoutSeconds > 0 must actually close a genuinely idle // connection. This is the test that would fail (hang / never see EOF) if // the reaper were a no-op, never invoked, or its age comparison were // backwards. bool testIdleConnectionGetsClosed() { std::cout << "[idle_reaper_test] scenario 1: idleTimeoutSeconds=2 closes an idle connection" << std::endl; const int idleTimeoutSeconds = 2; tcp serverSocket("127.0.0.1", 18085, 1024, -1); IdleReaperServer srv({&serverSocket}, /*timeout(ms)=*/200, idleTimeoutSeconds); return withRunningServer(srv, [&]() -> bool { tcp client; client.connect("127.0.0.1", 18085); client.setTimeout(5000); // Deliberately send nothing at all. lasteventime is stamped at // connect time (con's constructor), so the idle clock is already // running -- no data exchange is required to age this connection. // Wait well past: idleTimeoutSeconds (2s) + the (overridden) 1s // sweep throttle + generous scheduling slack, so this isn't a // razor's-edge timing test. std::this_thread::sleep_for(std::chrono::seconds(idleTimeoutSeconds + 4)); buffer recvbuf(64); try { size_t n = client.recvData(recvbuf); std::cerr << " FAIL: expected the server to have closed the idle connection, " "but recvData returned " << n << " bytes instead of EOF" << std::endl; client.close(); return false; } catch (NetException &e) { client.close(); if (e.getErrorType() == NetException::Error) { std::cout << " OK: client observed connection closed by server (" << e.what() << ")" << std::endl; return true; } std::cerr << " FAIL: recvData threw, but not a peer-closed error: " << e.what() << std::endl; return false; } }); } // Scenario 2 (negative case): idleTimeoutSeconds == 0 is the documented // default and must leave a connection that goes equally idle completely // untouched. This is the test that would fail if the reaper ignored the // "0 = disabled" contract (e.g. treated 0 as "no minimum" and reaped // everything, or a copy/paste bug always enabled the sweep regardless of // the configured value). bool testDefaultDisabledLeavesConnectionAlive() { std::cout << "[idle_reaper_test] scenario 2: idleTimeoutSeconds=0 (default) leaves an " "equally idle connection alive" << std::endl; tcp serverSocket("127.0.0.1", 18086, 1024, -1); // idleTimeoutSeconds intentionally 0 -- opt-out / unchanged default behavior. IdleReaperServer srv({&serverSocket}, /*timeout(ms)=*/200, /*idleTimeoutSeconds=*/0); return withRunningServer(srv, [&]() -> bool { tcp client; client.connect("127.0.0.1", 18086); client.setTimeout(5000); // Same idle wait as scenario 1, so this is a genuine apples-to-apples // comparison, not just a shorter/easier wait. std::this_thread::sleep_for(std::chrono::seconds(2 + 4)); // Prove the connection is still alive and fully functional, not // merely "the fd hasn't been closed yet": do a real round-trip. const std::string request = "ping"; bool ok = false; try { buffer sendbuf(request.data(), request.size()); client.sendData(sendbuf); buffer recvbuf(64); size_t n = client.recvData(recvbuf); std::string response(recvbuf.data.buf, n); if (response.find("still-alive") != std::string::npos) { std::cout << " OK: connection still alive, request/response round-trip " "succeeded after idling" << std::endl; ok = true; } else { std::cerr << " FAIL: unexpected response after idling: " << response << std::endl; } } catch (NetException &e) { std::cerr << " FAIL: connection was closed (or otherwise broken) despite " "idleTimeoutSeconds=0: " << e.what() << std::endl; } client.close(); return ok; }); } } // namespace int main() { // Must be set before the very first reapIdleConnections() call in the // process picks up (and caches) the sweep interval -- see the file // banner comment above for why this env var exists at all. setenv("NETPLUS_IDLE_SWEEP_INTERVAL_SECONDS", "1", 1); bool pass1 = testIdleConnectionGetsClosed(); bool pass2 = testDefaultDisabledLeavesConnectionAlive(); if (pass1 && pass2) { std::cout << "idle_reaper_test PASSED" << std::endl; return 0; } std::cerr << "idle_reaper_test FAILED (scenario1=" << pass1 << " scenario2=" << pass2 << ")" << std::endl; return 1; }