yams/tests/unit/extraction/plugin_process_launcher_test.cpp

433 lines
15 KiB
C++

/**
* @file plugin_process_launcher_test.cpp
* @brief Comprehensive tests for PluginProcess launcher on Windows and Unix
*
* These tests specifically target process spawning and I/O handling to
* isolate issues with CreateProcessW error 87 and pipe communication.
*
* Note: Tests have short timeouts to prevent hanging. If a test times out,
* it indicates a bug in the process spawning or I/O handling.
*/
// pi-lens-ignore: fatal error
#include <yams/extraction/plugin_process.hpp>
#include <nlohmann/json.hpp>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <filesystem>
#include <string>
#include <thread>
// External plugins can need time to initialize before accepting requests.
constexpr auto PROCESS_STARTUP_WAIT = std::chrono::seconds{3};
// PyInstaller executables may need extra time to respond after stdin is closed
constexpr auto RPC_TIMEOUT = std::chrono::seconds{8};
using namespace yams::extraction;
using json = nlohmann::json;
namespace fs = std::filesystem;
namespace {
// Helper: convert string to byte span
std::span<const std::byte> to_bytes(std::string_view str) {
return std::as_bytes(std::span{str.data(), str.size()});
}
// Helper: convert byte span to string
std::string from_bytes(std::span<const std::byte> data) {
return {reinterpret_cast<const char*>(data.data()), data.size()};
}
// Get platform-appropriate Python executable
std::string getPythonExecutable() {
const char* python_env = std::getenv("PYTHON");
if (python_env)
return python_env;
#ifdef _WIN32
return "python";
#else
return "python3";
#endif
}
// Get mock plugin path - uses YAMS_SOURCE_DIR if available, otherwise relative to __FILE__
fs::path getMockPluginPath() {
// Try environment variable first (set by meson/cmake)
const char* source_dir = std::getenv("YAMS_SOURCE_DIR");
if (source_dir) {
return fs::path(source_dir) / "tests" / "fixtures" / "mock_plugin.py";
}
// Fall back to relative path from __FILE__
// __FILE__ = tests/unit/extraction/plugin_process_launcher_test.cpp
// We want: tests/fixtures/mock_plugin.py
fs::path file_path = fs::absolute(fs::path(__FILE__));
fs::path tests_dir = file_path.parent_path().parent_path().parent_path();
return tests_dir / "fixtures" / "mock_plugin.py";
}
// Helper: read stdout with timeout, return string
std::string readStdoutWithTimeout(PluginProcess& process,
std::chrono::milliseconds timeout = RPC_TIMEOUT) {
auto start = std::chrono::steady_clock::now();
size_t lastSize = 0;
bool processWasAlive = true;
while (std::chrono::steady_clock::now() - start < timeout) {
auto data = process.read_stdout();
if (data.size() > lastSize) {
lastSize = data.size();
// Check if we have a complete line
std::string_view content{reinterpret_cast<const char*>(data.data()), data.size()};
if (content.find('\n') != std::string_view::npos) {
return std::string{content};
}
}
// If process just died, give IO thread time to drain the pipe
if (!process.is_alive()) {
if (processWasAlive) {
// Process just died - wait a bit for IO thread to finish reading
processWasAlive = false;
std::this_thread::sleep_for(std::chrono::milliseconds{100});
continue;
}
// Process was already dead and we've waited - check one more time
data = process.read_stdout();
if (data.size() > lastSize) {
std::string_view content{reinterpret_cast<const char*>(data.data()), data.size()};
if (content.find('\n') != std::string_view::npos) {
return std::string{content};
}
}
// No more data coming, return what we have
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
// Return whatever we have
auto data = process.read_stdout();
return from_bytes(data);
}
#ifdef _WIN32
std::string resolvePowerShellExecutable() {
auto exists = [](const char* path) { return fs::exists(fs::path(path)); };
if (exists(R"(C:\Program Files\PowerShell\7\pwsh.exe)")) {
return R"(C:\Program Files\PowerShell\7\pwsh.exe)";
}
if (exists(R"(C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe)")) {
return R"(C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe)";
}
return "powershell.exe";
}
#endif
} // namespace
TEST_CASE("PluginProcess launcher basic spawn", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Spawn Python script directly") {
INFO("Spawning: " << python << " " << mock_plugin.string());
PluginProcessConfig config{
.executable = python, .args = {"-u", mock_plugin.string()} // -u for unbuffered
};
PluginProcess process{config};
CHECK(process.is_alive());
CHECK((process.state() == ProcessState::Ready));
CHECK((process.pid() > 0));
INFO("PID: " << process.pid());
// Give process time to initialize
std::this_thread::sleep_for(std::chrono::milliseconds{200});
CHECK(process.is_alive());
process.terminate();
CHECK((process.state() == ProcessState::Terminated));
}
}
TEST_CASE("PluginProcess launcher stdin/stdout", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Write to stdin and read from stdout") {
PluginProcessConfig config{.executable = python, .args = {"-u", mock_plugin.string()}};
PluginProcess process{config};
REQUIRE(process.is_alive());
// Wait for process to be ready
std::this_thread::sleep_for(PROCESS_STARTUP_WAIT);
// Send handshake request
json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "handshake.manifest"}};
std::string request_str = request.dump() + "\n";
INFO("Sending request: " << request_str);
size_t written = process.write_stdin(to_bytes(request_str));
INFO("Wrote " << written << " bytes");
CHECK((written == request_str.size()));
// Read response
std::string response = readStdoutWithTimeout(process, RPC_TIMEOUT);
INFO("Received: " << response);
REQUIRE_FALSE(response.empty());
// Parse and validate response
auto parsed = json::parse(response);
CHECK((parsed["jsonrpc"] == "2.0"));
CHECK((parsed["id"] == 1));
CHECK(parsed.contains("result"));
CHECK((parsed["result"]["name"] == "mock_plugin"));
process.terminate();
}
}
TEST_CASE("PluginProcess launcher environment variables", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Pass environment variables to child process") {
PluginProcessConfig config{
.executable = python,
.args = {"-u", mock_plugin.string()},
.env = {{"PYTHONUNBUFFERED", "1"}, {"YAMS_TEST_VAR", "test_value"}}};
PluginProcess process{config};
REQUIRE(process.is_alive());
// Give process time to initialize
std::this_thread::sleep_for(std::chrono::milliseconds{200});
CHECK(process.is_alive());
process.terminate();
}
}
TEST_CASE("PluginProcess launcher working directory", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Set working directory") {
fs::path workdir = mock_plugin.parent_path();
PluginProcessConfig config{.executable = python,
.args = {"-u", mock_plugin.string()},
.env = {},
.workdir = workdir};
INFO("Workdir: " << workdir.string());
PluginProcess process{config};
REQUIRE(process.is_alive());
std::this_thread::sleep_for(std::chrono::milliseconds{200});
CHECK(process.is_alive());
process.terminate();
}
}
#ifdef _WIN32
TEST_CASE("PluginProcess launcher Windows-specific", "[extraction][plugin][launcher][windows]") {
SECTION("Spawn cmd.exe with echo") {
// Simple test - spawn cmd.exe and run echo
PluginProcessConfig config{.executable = "cmd.exe", .args = {"/c", "echo", "hello"}};
PluginProcess process{config};
// cmd.exe /c echo will exit immediately
std::this_thread::sleep_for(std::chrono::milliseconds{500});
auto output = from_bytes(process.read_stdout());
INFO("Output: " << output);
// Process should have exited
CHECK_FALSE(process.is_alive());
}
SECTION("Spawn powershell with Write-Output") {
PluginProcessConfig config{.executable = resolvePowerShellExecutable(),
.args = {"-NoProfile", "-Command",
"[Console]::Out.WriteLine('hello from powershell')"}};
PluginProcess process{config};
auto output = readStdoutWithTimeout(process, std::chrono::seconds{5});
INFO("Output: " << output);
CHECK(output.find("hello") != std::string::npos);
}
SECTION("Spawn with absolute path containing spaces") {
// Create a temp directory with spaces
fs::path tempDir = fs::temp_directory_path() / "yams test dir";
fs::create_directories(tempDir);
// Copy mock_plugin.py there
fs::path mockPlugin = getMockPluginPath();
fs::path destPlugin = tempDir / "mock_plugin.py";
fs::copy_file(mockPlugin, destPlugin, fs::copy_options::overwrite_existing);
std::string python = getPythonExecutable();
PluginProcessConfig config{.executable = python, .args = {"-u", destPlugin.string()}};
INFO("Path with spaces: " << destPlugin.string());
PluginProcess process{config};
REQUIRE(process.is_alive());
std::this_thread::sleep_for(std::chrono::milliseconds{200});
CHECK(process.is_alive());
// Send request
json request = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "handshake.manifest"}};
process.write_stdin(to_bytes(request.dump() + "\n"));
std::string response = readStdoutWithTimeout(process);
INFO("Response: " << response);
CHECK_FALSE(response.empty());
process.terminate();
// Cleanup
fs::remove(destPlugin);
fs::remove(tempDir);
}
}
#endif
TEST_CASE("PluginProcess launcher rapid spawn/terminate", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Spawn and immediately terminate") {
for (int i = 0; i < 5; i++) {
INFO("Iteration " << i);
PluginProcessConfig config{.executable = python, .args = {"-u", mock_plugin.string()}};
PluginProcess process{config};
CHECK(process.is_alive());
process.terminate();
CHECK((process.state() == ProcessState::Terminated));
}
}
SECTION("Multiple processes simultaneously") {
std::vector<std::unique_ptr<PluginProcess>> processes;
for (int i = 0; i < 3; i++) {
PluginProcessConfig config{.executable = python, .args = {"-u", mock_plugin.string()}};
processes.push_back(std::make_unique<PluginProcess>(std::move(config)));
}
// All should be alive
for (size_t i = 0; i < processes.size(); i++) {
INFO("Process " << i);
CHECK(processes[i]->is_alive());
}
// Terminate all
for (auto& p : processes) {
p->terminate();
}
}
}
TEST_CASE("PluginProcess launcher I/O timing", "[extraction][plugin][launcher]") {
std::string python = getPythonExecutable();
fs::path mock_plugin = getMockPluginPath();
REQUIRE(fs::exists(mock_plugin));
SECTION("Multiple requests in sequence") {
PluginProcessConfig config{.executable = python, .args = {"-u", mock_plugin.string()}};
PluginProcess process{config};
REQUIRE(process.is_alive());
std::this_thread::sleep_for(std::chrono::milliseconds{200});
size_t read_pos = 0;
for (int i = 1; i <= 3; i++) {
json request = {{"jsonrpc", "2.0"}, {"id", i}, {"method", "handshake.manifest"}};
process.write_stdin(to_bytes(request.dump() + "\n"));
// Poll for a complete response line instead of relying on a fixed sleep: under
// parallel CI load the plugin process can be starved past 100ms, leaving only a
// partial line (no '\n') so read_pos never advances and later requests parse the
// wrong response. Bounded deadline keeps the test deterministic and fast on the
// happy path.
bool gotLine = false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5};
while (std::chrono::steady_clock::now() < deadline) {
auto data = process.read_stdout();
INFO("Request " << i << ": buffer size = " << data.size()
<< ", read_pos = " << read_pos);
if (data.size() > read_pos) {
std::string_view remaining{
reinterpret_cast<const char*>(data.data() + read_pos),
data.size() - read_pos};
size_t newline = remaining.find('\n');
if (newline != std::string_view::npos) {
std::string line{remaining.substr(0, newline)};
read_pos += newline + 1;
auto parsed = json::parse(line);
CHECK((parsed["id"] == i));
gotLine = true;
break;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds{20});
}
REQUIRE(gotLine);
}
process.terminate();
}
}
TEST_CASE("PluginProcess launcher error handling", "[extraction][plugin][launcher]") {
SECTION("Non-existent executable throws") {
PluginProcessConfig config{.executable = "this_executable_does_not_exist_12345"};
CHECK_THROWS([&]() { PluginProcess process{config}; }());
}
SECTION("Invalid executable throws") {
// Try to execute a directory
PluginProcessConfig config{.executable = fs::temp_directory_path()};
CHECK_THROWS([&]() { PluginProcess process{config}; }());
}
}