Commit 61f6b4fb authored by jan.koester's avatar jan.koester
Browse files

test

parent e378f7e4
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -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)
+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;
}