diff --git a/CMakeLists.txt b/CMakeLists.txt index 70c3bf352..0a706cff4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1806,3 +1806,29 @@ if(EXISTS "${_AUTO_TUNE_TEST_SRC}") include(CTest) add_test(NAME AutoTuneTest COMMAND test_auto_tune) endif() + +# ProcessManager non-mutating is_running() contract test. +set(_PM_TEST_SRC + "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_process_manager.cpp" +) +set(_PM_TEST_LIBS + "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/server/utils/process_manager.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/server/utils/platform/process_unix.cpp" +) +list(GET _PM_TEST_LIBS 0 _pm_lib0) +list(GET _PM_TEST_LIBS 1 _pm_lib1) +if(EXISTS "${_PM_TEST_SRC}" AND EXISTS "${_pm_lib0}" AND EXISTS "${_pm_lib1}") + add_executable(test_process_manager + test/cpp/test_process_manager.cpp + src/cpp/server/utils/process_manager.cpp + src/cpp/server/utils/platform/process_unix.cpp + ) + target_include_directories(test_process_manager PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/cpp/include + ${CMAKE_CURRENT_BINARY_DIR}/include + ) + target_link_libraries(test_process_manager PRIVATE pthread) + target_link_options(test_process_manager PRIVATE -pthread) + include(CTest) + add_test(NAME ProcessManagerTest COMMAND test_process_manager) +endif() diff --git a/src/cpp/server/utils/platform/process_unix.cpp b/src/cpp/server/utils/platform/process_unix.cpp index 81e3b4250..791ccb17b 100644 --- a/src/cpp/server/utils/platform/process_unix.cpp +++ b/src/cpp/server/utils/platform/process_unix.cpp @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #ifdef __linux__ #include @@ -82,6 +85,36 @@ static void preserve_capabilities_for_exec() { } #endif +// Check whether a process is a zombie by reading /proc//stat. +// This is non-mutating — it does not reap the child. +static bool is_zombie_by_proc(pid_t pid) { + char path[64]; + std::snprintf(path, sizeof(path), "/proc/%d/stat", pid); + std::ifstream f(path); + if (!f.is_open()) { + return false; + } + std::string line; + if (!std::getline(f, line)) { + return false; + } + auto open_paren = line.find('('); + if (open_paren == std::string::npos) { + return false; + } + auto close_paren = line.rfind(')'); + if (close_paren == std::string::npos || close_paren <= open_paren) { + return false; + } + std::string rest = line.substr(close_paren + 1); + std::istringstream iss(rest); + char state; + if (!(iss >> state)) { + return false; + } + return state == 'Z'; +} + class UnixProcessPlatform : public ProcessPlatform { public: ProcessHandle spawn( @@ -370,6 +403,10 @@ bool UnixProcessPlatform::is_running(ProcessHandle handle) { } #endif + if (is_zombie_by_proc(handle.pid)) { + return false; + } + errno = 0; return ::kill(handle.pid, 0) == 0 || errno == EPERM; } diff --git a/test/cpp/test_process_manager.cpp b/test/cpp/test_process_manager.cpp new file mode 100644 index 000000000..71e7cbf60 --- /dev/null +++ b/test/cpp/test_process_manager.cpp @@ -0,0 +1,172 @@ +// Standalone test for ProcessManager::is_running() non-mutating contract. +// Verifies: +// 1. is_running() returns true for a running process +// 2. is_running() returns false for an exited process WITHOUT reaping it +// 3. reap_process() retrieves the real exit code after is_running() returned false + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using lemon::utils::ProcessHandle; +using lemon::utils::ProcessManager; + +static int g_failures = 0; + +static void check(const char* name, bool ok) { + std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name); + if (!ok) ++g_failures; +} + +static pid_t spawn_child(int exit_code) { + pid_t pid = fork(); + if (pid < 0) { + return -1; + } + if (pid == 0) { + _exit(exit_code); + } + return pid; +} + +static ProcessHandle make_handle(pid_t pid) { + ProcessHandle h; + h.handle = nullptr; + h.pid = static_cast(pid); + return h; +} + +// Wait for a child to exit without reaping it. +// Uses /proc//stat to detect zombie state. +static pid_t wait_for_exit_no_reap(pid_t child, int timeout_ms = 5000) { + auto start = std::chrono::steady_clock::now(); + while (true) { + { + char path[64]; + std::snprintf(path, sizeof(path), "/proc/%d/stat", child); + std::ifstream f(path); + if (f.is_open()) { + std::string line; + if (std::getline(f, line)) { + auto open_paren = line.find('('); + if (open_paren != std::string::npos) { + auto close_paren = line.rfind(')'); + if (close_paren != std::string::npos && close_paren > open_paren) { + std::string rest = line.substr(close_paren + 1); + std::istringstream iss(rest); + char state; + if (iss >> state && state == 'Z') { + return child; + } + } + } + } + } + } + int status = 0; + pid_t r = waitpid(child, &status, WNOHANG); + if (r == child) { + return -1; + } + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(now - start).count() > timeout_ms) { + return -1; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } +} + +int main() { + // Test: is_running() returns false for an exited child + { + pid_t child = spawn_child(42); + check("spawn_child returns valid PID", child > 0); + + pid_t exited = wait_for_exit_no_reap(child); + check("child exited (detected without reap)", exited > 0); + + if (exited > 0) { + ProcessHandle h = make_handle(exited); + + bool running = ProcessManager::is_running(h); + check("is_running() returns false for exited child", !running); + + int exit_code = ProcessManager::reap_process(h); + check("reap_process() returns real exit code 42", exit_code == 42); + } else { + int status = 0; + waitpid(child, &status, 0); + } + } + + // Test: edge cases for invalid PIDs + { + ProcessHandle h = make_handle(0); + check("is_running() returns false for PID 0", !ProcessManager::is_running(h)); + } + { + ProcessHandle h = make_handle(-1); + check("is_running() returns false for negative PID", !ProcessManager::is_running(h)); + } + { + ProcessHandle h = make_handle(999999); + check("is_running() returns false for non-existent PID", !ProcessManager::is_running(h)); + } + + // Test: is_running() returns true for a running process + { + pid_t child = fork(); + check("fork succeeds", child >= 0); + + if (child > 0) { + ProcessHandle h = make_handle(child); + check("is_running() returns true for running child", ProcessManager::is_running(h)); + + kill(child, SIGKILL); + int status = 0; + waitpid(child, &status, 0); + } else if (child == 0) { + while (true) { + sleep(1); + } + } + } + + // Test: reap_process() returns -1 for a still-running process + { + pid_t child = fork(); + check("fork for reap test", child >= 0); + + if (child > 0) { + ProcessHandle h = make_handle(child); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + int rc = ProcessManager::reap_process(h); + check("reap_process() returns -1 for running process", rc == -1); + + kill(child, SIGKILL); + int status = 0; + waitpid(child, &status, 0); + } else if (child == 0) { + while (true) { + sleep(1); + } + } + } + + if (g_failures == 0) { + std::printf("\nAll process_manager tests passed\n"); + return 0; + } + std::printf("\n%d process_manager test(s) FAILED\n", g_failures); + return 1; +}