* test(memory-sync): stop the sync service off the apply lock in stage hooks Two integration tests stop the sync service from a stage observer to simulate a stop during apply or backfill. The observer runs under ServiceManager's apply lock, and the blocking stop() then takes the service's callback lock, the reverse of the worker's order (callback lock, then apply lock in applyMemorySyncWinners). TSan on a gcc Debug build reports a lock-order inversion in daemon_memory_sync_apply_stop and daemon_memory_sync_backfill_stop. stop() also joins the worker, which can be blocked on that apply lock, so the test can hang. Production shutdown calls stop() without the apply lock, so this is a test hazard only. StopFromStage runs stop() on a helper thread, returns once stopRequested() is visible, and joins after the stage call returns. Signed-off-by: trvon <me@trevon.dev> * fix(cli): keep the memory-sync status request alive until the call runs (#353) fetchMemorySyncStatus passed a temporary MemorySyncRequest to DaemonClient::call(), which is a coroutine taking the request by reference, and returned the awaitable from the provider lambda. runDaemonClient awaits it only after the lambda returns, so the call read a destroyed request. Under TSan on a gcc Debug build, `yams daemon status -d` aborted while copying the request into the Request variant: "requested allocation size 0x7ffed4d77a51" (a stack address read as the key length). That failed integration_smoke. ASan does not flag it without stack-use-after-return detection. The provider is now a coroutine lambda that owns the request. The other providers that return call() awaitables capture their request by value and keep the closure alive while awaiting (awaitResultSync), so they are not affected. Signed-off-by: trvon <me@trevon.dev> * fix(extraction): return plugin output as a copy taken under its lock (#354) PluginProcess::read_stdout() and read_stderr() returned a span into the output buffer after releasing the buffer mutex. The reader thread keeps appending to that buffer, and a reallocation frees the storage the span points to. JsonRpcClient::read_next_response() scans that span for a newline, so a plugin that writes while a response is being parsed reads freed memory. TSan on a gcc Debug build reports the race in plugin_process (read_stdout_loop's insert against the test's unlocked read). Both accessors now return std::vector<std::byte>, copied under the lock. Callers use auto or pass the result as a span, so they are unchanged. Signed-off-by: trvon <me@trevon.dev> * fix(client): stop the write loop once close() has reset the socket (#355) close() resets AsioConnection::socket on the strand. A write loop that still had frames queued, or a new async_write_frame() after close, went on to async_write(*socket, ...) through the null unique_ptr. Debug builds with _GLIBCXX_ASSERTIONS abort there; other builds bind a reference to null. Seen on a gcc Debug TSan build: client_timeout_recovery aborted at asio_connection.cpp:233, and Catch2's fatal-signal handler then allocated inside the SIGABRT handler (TSan "signal-unsafe call") and hung until the 60 s test timeout. The write loop now ends with NetworkError when the socket is gone or closed, mirroring the read loop, and the timeout path checks the socket before closing it. Signed-off-by: trvon <me@trevon.dev> * test(tsan): apply the suppressions file to every test (#356) tsan.suppressions reached only the CLI, smoke, search and services suites, the ones whose test() env sets TSAN_OPTIONS. Every other test ran unsuppressed. A default 'tsan' test setup now sets TSAN_OPTIONS for all tests in a TSan build. Tests that already set it use the same value. Two narrow rules cover false positives that a gcc TSan build reports and clang does not: - boost::asio::detail::ref_count_down: fetch_sub(release) then a standalone acquire fence, which gcc TSan does not model (PostIngestQueue, VectorIndexCoordinator, WorkCoordinator reports). - walIndexTryHdr / walIndexWriteHdr: SQLite's lock-free WAL-index header protocol. SQLITE_NO_TSAN is clang-only (metadata commit, symspell commit, KG path node reports). Signed-off-by: trvon <me@trevon.dev> * fix(daemon): run ServiceManager teardown even when the shared FSM is stopped (#357) ServiceManagerFsm keeps its state in tinyfsm's process-wide storage. ServiceManager::shutdown() returned early when that state was already ShuttingDown or Stopped, so another manager's shutdown in the same process cancelled this one's teardown. The common case: a second daemon that loses the instance lock destroys its never-started ServiceManager, whose destructor runs shutdown() and drives the shared FSM to Stopped. The running daemon's stop() then skipped every shutdown phase. Its WorkCoordinator threads and background coroutines kept running and kept the ServiceManager alive. gcc TSan reported the result in daemon_catch2's concurrent-start test: a worker still inside the FTS5 consumer read TuningSnapshotRegistry after exit() had destroyed it (heap-use-after-free). shutdownInvoked_ is the per-instance guard. The shared state now only decides whether to dispatch ShutdownEvent, and completeShutdown() already accepts a Stopped state. A new test starts a daemon, loses a second start against it, stops the first, and checks that its ServiceManager is released. Signed-off-by: trvon <me@trevon.dev> * fix(daemon): leave the repair hysteresis timestamps to the run loop (#358) stop() cleared repairBusySince_ and repairReadySince_ from the caller's thread while the runLoop thread updates them in updateRepairHysteresis(). TSan on a gcc Debug build reports the race in lifecycle_single_instance_release and grep_service_expectations (daemon.cpp:1141/1142 against runLoop at 914). Only runLoop uses these fields, and start() resets them before the loop thread is created, so stop() no longer touches them. Signed-off-by: trvon <me@trevon.dev> * test(sync-indexing): scale the indexing waits with the test timeout scale (#359) sync_indexing_catch2 waits a fixed 3 s for a freshly added document to show up in grep, with a 1.5 s per-call timeout and a 2 s timing check. Under gcc TSan everything runs about 10x slower, so "Sync add then immediate grep" failed REQUIRE(found) at 3 s. The test now gets _test_timeout_cpp_arg like the smoke tests and multiplies those three bounds by YAMS_TEST_TIMEOUT_SCALE (1 in normal builds, 10 under TSan). Signed-off-by: trvon <me@trevon.dev> * fix(build): keep the S3 plugin mapped in Linux TSan builds (#360) storage_submodule passed every test (3167 assertions) under gcc TSan and then crashed in exit(). The S3 plugin links its own static OpenSSL, whose init registers atexit(OPENSSL_cleanup) from inside the plugin. glibc ties that handler to the DSO and runs it at dlclose, but TSan's atexit interceptor keeps it until process exit and calls into the unmapped plugin (gdb: at_exit_callback_installed_at -> unmapped pc in OPENSSL_cleanup of libyams_object_storage_s3.so). Linux TSan builds now link the plugin with -z nodelete. Other builds are unchanged. Signed-off-by: trvon <me@trevon.dev> * test(cli): scale the serve_delegation per-call timeout with the build (#361) serve_delegation copies the yams CLI into a temp dir and runs it with a fixed 10 s timeout. In an ASan build the binary is over 500 MB, and on macOS the first exec of a freshly copied executable waits for the system policy scan, so under load a call can exceed 10 s. The pre-push ASan lane failed this way twice with no product change. The script now takes --timeout-scale, and meson passes the build's test timeout scale: 1 by default, 3 in the ASan lane, 10 under TSan. With scale 3 the suite passes in 50 s at load average 24. Signed-off-by: trvon <me@trevon.dev> * test(daemon): scale the socket probe budgets in sanitizer builds (#362) "unix_socket_io_permitted: real probe completes within budget" requires the first probe in under 100 ms and the cached call in under 50 us. On a gcc ASan build running tests in parallel, the first probe missed 100 ms while the code under test was unchanged. The budgets are now 10x under ASan or TSan, detected at compile time, and unchanged in normal builds. Signed-off-by: trvon <me@trevon.dev> * build(tsan): use atomic coverage counters in TSan + coverage builds (#363) gcov counters are plain increments. A gcc Debug build with -Db_sanitize=thread -Db_coverage=true reported every counter that two threads touch as a data race (`Location is global '__gcov0...'`), 32 reports in the first 43 tests and none in YAMS code. When b_coverage is on in a TSan build, the project now adds -fprofile-update=atomic for C and C++. Other builds are unchanged. Signed-off-by: trvon <me@trevon.dev> --------- Signed-off-by: trvon <me@trevon.dev>
334 lines
12 KiB
C++
334 lines
12 KiB
C++
#include <yams/extraction/plugin_process.hpp>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <stdexcept>
|
|
#include <thread>
|
|
|
|
using namespace yams::extraction;
|
|
using json = nlohmann::json;
|
|
|
|
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()};
|
|
}
|
|
|
|
// Helper: send JSON-RPC request
|
|
void send_request(PluginProcess& process, int id, std::string_view method, json params = nullptr) {
|
|
json request = {{"jsonrpc", "2.0"}, {"id", id}, {"method", method}};
|
|
if (!params.is_null()) {
|
|
request["params"] = params;
|
|
}
|
|
std::string request_str = request.dump() + "\n";
|
|
process.write_stdin(to_bytes(request_str));
|
|
|
|
// Give plugin time to process
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{100});
|
|
}
|
|
|
|
// Helper: read JSON-RPC response (with timeout)
|
|
// Reads from stdout buffer starting at given position
|
|
std::optional<json> read_response(PluginProcess& process, size_t& read_pos,
|
|
std::chrono::milliseconds timeout = std::chrono::seconds{3}) {
|
|
auto start = std::chrono::steady_clock::now();
|
|
|
|
while (std::chrono::steady_clock::now() - start < timeout) {
|
|
auto data = process.read_stdout();
|
|
|
|
if (data.size() > read_pos) {
|
|
// We have new data
|
|
std::string_view remaining{reinterpret_cast<const char*>(data.data() + read_pos),
|
|
data.size() - read_pos};
|
|
|
|
// Look for newline
|
|
size_t newline_pos = remaining.find('\n');
|
|
if (newline_pos != std::string::npos) {
|
|
std::string line{remaining.substr(0, newline_pos)};
|
|
read_pos += newline_pos + 1;
|
|
|
|
if (!line.empty()) {
|
|
return json::parse(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{10});
|
|
}
|
|
|
|
// Timeout - log what we have for debugging
|
|
auto data = process.read_stdout();
|
|
INFO("read_response timeout after " << timeout.count() << "ms. Buffer size: " << data.size()
|
|
<< ", read_pos: " << read_pos
|
|
<< ", process alive: " << process.is_alive());
|
|
|
|
return std::nullopt;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// Helper: Get platform-appropriate Python executable
|
|
namespace {
|
|
std::string getPythonExecutable() {
|
|
const char* python_env = std::getenv("PYTHON");
|
|
if (python_env) {
|
|
return python_env;
|
|
}
|
|
#ifdef _WIN32
|
|
return "python"; // Windows uses 'python' not 'python3'
|
|
#else
|
|
return "python3"; // Unix typically uses python3
|
|
#endif
|
|
}
|
|
|
|
// Helper: Get mock plugin path relative to test source file
|
|
std::filesystem::path getMockPluginPath() {
|
|
// __FILE__ is tests/unit/extraction/plugin_process_test.cpp
|
|
// Navigate up 3 levels to tests/, then into fixtures/
|
|
return std::filesystem::path(__FILE__).parent_path().parent_path().parent_path() / "fixtures" /
|
|
"mock_plugin.py";
|
|
}
|
|
} // namespace
|
|
|
|
TEST_CASE("PluginProcess spawns and terminates", "[extraction][plugin]") {
|
|
// Get Python interpreter and mock plugin path
|
|
std::string python = getPythonExecutable();
|
|
std::filesystem::path mock_plugin = getMockPluginPath();
|
|
|
|
INFO("Python executable: " << python);
|
|
INFO("Mock plugin path: " << mock_plugin.string());
|
|
REQUIRE(std::filesystem::exists(mock_plugin));
|
|
|
|
SECTION("Basic lifecycle") {
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
|
|
PluginProcess process{std::move(config)};
|
|
|
|
REQUIRE(process.is_alive());
|
|
REQUIRE(process.state() == ProcessState::Ready);
|
|
REQUIRE(process.pid() > 0);
|
|
|
|
process.terminate();
|
|
REQUIRE(process.state() == ProcessState::Terminated);
|
|
REQUIRE_FALSE(process.is_alive());
|
|
}
|
|
|
|
SECTION("RAII termination") {
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
|
|
{
|
|
PluginProcess process{std::move(config)};
|
|
REQUIRE(process.is_alive());
|
|
} // Destructor should terminate process
|
|
}
|
|
|
|
SECTION("Move semantics") {
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
|
|
PluginProcess process1{std::move(config)};
|
|
REQUIRE(process1.is_alive());
|
|
int64_t pid1 = process1.pid();
|
|
|
|
// Move construct
|
|
PluginProcess process2{std::move(process1)};
|
|
REQUIRE(process2.is_alive());
|
|
REQUIRE(process2.pid() == pid1);
|
|
|
|
// Move assign
|
|
PluginProcessConfig config2{.executable = python, .args = {mock_plugin.string()}};
|
|
PluginProcess process3{std::move(config2)};
|
|
process3 = std::move(process2);
|
|
REQUIRE(process3.is_alive());
|
|
REQUIRE(process3.pid() == pid1);
|
|
}
|
|
|
|
SECTION("Uptime tracking") {
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
|
|
PluginProcess process{std::move(config)};
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{100});
|
|
|
|
auto uptime = process.uptime();
|
|
REQUIRE(uptime >= std::chrono::milliseconds{100});
|
|
REQUIRE(uptime < std::chrono::seconds{1});
|
|
}
|
|
}
|
|
|
|
TEST_CASE("PluginProcess I/O communication", "[extraction][plugin]") {
|
|
std::string python = getPythonExecutable();
|
|
std::filesystem::path mock_plugin = getMockPluginPath();
|
|
|
|
INFO("Python executable: " << python);
|
|
INFO("Mock plugin path: " << mock_plugin.string());
|
|
REQUIRE(std::filesystem::exists(mock_plugin));
|
|
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
|
|
PluginProcess process{std::move(config)};
|
|
REQUIRE(process.is_alive());
|
|
|
|
size_t read_pos = 0; // Track our position in stdout buffer
|
|
|
|
SECTION("Handshake manifest") {
|
|
send_request(process, 1, "handshake.manifest");
|
|
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
|
|
REQUIRE((*response)["jsonrpc"] == "2.0");
|
|
REQUIRE((*response)["id"] == 1);
|
|
REQUIRE((*response).contains("result"));
|
|
|
|
auto result = (*response)["result"];
|
|
REQUIRE(result["name"] == "mock_plugin");
|
|
REQUIRE(result["version"] == "1.0.0");
|
|
REQUIRE(result["interfaces"].is_array());
|
|
REQUIRE(result["interfaces"][0] == "content_extractor_v1");
|
|
}
|
|
|
|
SECTION("Plugin init") {
|
|
size_t read_pos = 0;
|
|
send_request(process, 2, "plugin.init", json::object());
|
|
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
REQUIRE((*response)["result"]["status"] == "ok");
|
|
}
|
|
|
|
SECTION("Plugin health") {
|
|
size_t read_pos = 0;
|
|
send_request(process, 3, "plugin.health");
|
|
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
REQUIRE((*response)["result"]["status"] == "ok");
|
|
}
|
|
|
|
SECTION("Extractor supports") {
|
|
size_t read_pos = 0;
|
|
json params = {{"mime_type", "text/plain"}, {"extension", ".txt"}};
|
|
send_request(process, 4, "extractor.supports", params);
|
|
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
REQUIRE((*response)["result"]["supported"] == true);
|
|
|
|
// Test unsupported type
|
|
json params2 = {{"mime_type", "application/pdf"}, {"extension", ".pdf"}};
|
|
send_request(process, 5, "extractor.supports", params2);
|
|
|
|
auto response2 = read_response(process, read_pos);
|
|
REQUIRE(response2.has_value());
|
|
REQUIRE((*response2)["result"]["supported"] == false);
|
|
}
|
|
|
|
SECTION("Extractor extract") {
|
|
size_t read_pos = 0;
|
|
json params = {{"source", {{"type", "bytes"}, {"data", "SGVsbG8gV29ybGQ="}}}};
|
|
send_request(process, 6, "extractor.extract", params);
|
|
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
|
|
auto result = (*response)["result"];
|
|
REQUIRE(result["text"] == "Mock extracted text from file");
|
|
REQUIRE(result["metadata"]["extractor"] == "mock_plugin");
|
|
REQUIRE(result["error"].is_null());
|
|
}
|
|
|
|
SECTION("Multiple sequential requests") {
|
|
size_t read_pos = 0;
|
|
for (int i = 1; i <= 5; ++i) {
|
|
send_request(process, i, "plugin.health");
|
|
auto response = read_response(process, read_pos);
|
|
REQUIRE(response.has_value());
|
|
REQUIRE((*response)["id"] == i);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("PluginProcess builder pattern configuration", "[extraction][plugin]") {
|
|
std::string python = getPythonExecutable();
|
|
std::filesystem::path mock_plugin = getMockPluginPath();
|
|
|
|
INFO("Python executable: " << python);
|
|
INFO("Mock plugin path: " << mock_plugin.string());
|
|
REQUIRE(std::filesystem::exists(mock_plugin));
|
|
|
|
SECTION("Builder methods") {
|
|
PluginProcessConfig config{.executable = python, .args = {mock_plugin.string()}};
|
|
auto current_dir = std::filesystem::current_path();
|
|
|
|
config.with_env("TEST_VAR", "test_value")
|
|
.with_timeout(std::chrono::seconds{30})
|
|
.in_directory(current_dir);
|
|
|
|
REQUIRE(config.env["TEST_VAR"] == "test_value");
|
|
REQUIRE(config.rpc_timeout == std::chrono::seconds{30});
|
|
REQUIRE(config.workdir == current_dir);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("PluginProcess handles invalid executable", "[extraction][plugin][!mayfail]") {
|
|
PluginProcessConfig config{.executable = "/nonexistent/binary", .args = {}};
|
|
REQUIRE_THROWS_AS(PluginProcess{std::move(config)}, std::runtime_error);
|
|
}
|
|
|
|
TEST_CASE("PluginProcess stderr capture", "[extraction][plugin]") {
|
|
std::string python = getPythonExecutable();
|
|
|
|
// Create a test script that writes to stderr
|
|
std::filesystem::path stderr_test = std::filesystem::temp_directory_path() / "stderr_test.py";
|
|
{
|
|
std::ofstream f{stderr_test};
|
|
// Use platform-agnostic shebang (not actually used on Windows, but harmless)
|
|
f << "import sys, json\n";
|
|
f << "print('stderr message', file=sys.stderr, flush=True)\n";
|
|
f << "for line in sys.stdin:\n";
|
|
f << " req = json.loads(line)\n";
|
|
f << " res = {'jsonrpc': '2.0', 'id': req['id'], 'result': {'status': 'ok'}}\n";
|
|
f << " print(json.dumps(res), flush=True)\n";
|
|
}
|
|
|
|
PluginProcessConfig config{
|
|
.executable = python, .args = {stderr_test.string()}, .redirect_stderr = true};
|
|
|
|
PluginProcess process{std::move(config)};
|
|
|
|
// Synchronize with the child instead of relying on a startup delay. The script writes and
|
|
// flushes stderr before reading this request, so a response proves the stderr write happened.
|
|
size_t read_pos = 0;
|
|
send_request(process, 1, "stderr_ready");
|
|
auto response = read_response(process, read_pos, std::chrono::seconds{10});
|
|
REQUIRE(response.has_value());
|
|
|
|
// Allow the independent stderr reader to publish the already-written bytes.
|
|
std::vector<std::byte> stderr_data;
|
|
auto start = std::chrono::steady_clock::now();
|
|
while (std::chrono::steady_clock::now() - start < std::chrono::seconds{5}) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds{100});
|
|
stderr_data = process.read_stderr();
|
|
if (!stderr_data.empty()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check that stderr was captured
|
|
INFO("stderr_data size: " << stderr_data.size());
|
|
INFO("Process alive: " << process.is_alive());
|
|
REQUIRE(!stderr_data.empty());
|
|
|
|
std::string stderr_str = from_bytes(stderr_data);
|
|
REQUIRE(stderr_str.find("stderr message") != std::string::npos);
|
|
|
|
std::filesystem::remove(stderr_test);
|
|
}
|