yams/tests/unit/daemon/request_handler_repair_cancel_catch2_test.cpp
Trevon bfe7cd94ca
test: expand storage-readiness coverage — 48.1%→59.8% broad, 74.3% narrow (#38)
* fix(daemon): avoid socket cancel on stop

Signal active sockets with shutdown only so pending I/O wakes without entering Boost.Asio's reactor cancel path during teardown.

* fix(metadata): open read pools read-only

* ci: fix coverage report generation

* fix(metadata): tag unscoped pool holders

* test(daemon): avoid ipc teardown takeover

* ci: skip plugin coverage objects

* fix(daemon): repair completed FTS5 ghosts

* build(vector): guard unusable FAISS headers

* ci: use clang gcov for coverage

* test(daemon): stabilize arm CI flakes

* Harden search and compression numeric config

* fix(daemon): reconcile orphan vector embeddings

* fix: address vector cleanup review comments

* chore(main): release 0.16.1

* Harden search ranking divisors

* Harden snippet distance and command registration

* test(storage): add readiness coverage gate

Document the distributed sync readiness surface and add direct adapter, refcount, and GC failure-path coverage.

* test(storage): cover strict resolver fallback

* fix(search): tighten retrieval ranking

Honor component weights in default RRF, over-fetch vector chunks before document aggregation, and persist topology centroids for sparse-guided routing.

* fix(search): route weak queries through topology

* fix(search): improve rerank and weak queries

Wire provider-backed cross reranking, add direct Simeon candidates for weak lexical pools, and report stage-level retrieval metrics.

* fix(storage): honor explicit plugin names

Add readiness coverage for factory, URL backend, and compressed storage failure paths.

* fix(storage): avoid remote size listing

* fix(api): rollback failed content adds

* test(storage): cover engine readiness

* test(storage): cover compressed readiness

* fix(core): harden delete and recovery paths

Route prune and local delete fallback through DocumentService so content, metadata, vectors, and KG cleanup stay consistent.

Treat transient SQLite quick_check lock failures as ResourceBusy, refresh IPC activity on streaming writes, and preserve reranker config in tuner seeding.

* test(storage): cover URL backend readiness

* fix(api): harden content rollback

* feat(storage): add list seam, URL listing, and write-edge coverage

* feat(api): reconcile ambiguous remote manifest writes

* feat(storage): add deep compressed scan validation

* test(storage): add plugin loader error path coverage

* fix(gc): prune reference rows after collection

* test(api,storage): add health, compact, gc, and verify edge coverage

* docs: update storage-readiness coverage to 71.8%

* test(metadata): add connection pool shutdown-while-leased coverage

* test(metadata): add tree builder snapshot root stability coverage

* test(vector): add before-init and after-close operation rejection coverage

* test(metadata): add database post-close, open-failure, and cache-clear coverage

* test(integrity): add P2P wrong-hash rejection + batch matrix resolution

* docs: audit and resolve all matrix rows — 18 fully covered

* test(metadata): add failed-migration and rollback coverage

* test(storage): add getBlockSize-invalid-key, empty-verify, PUT/DELETE retry coverage

* feat(storage): add file-open and rename failure hooks + ContentStore retrieve edge coverage

* docs: add broad-surface coverage with per-subsystem breakdown

* test(integrity): add chunk validator manifest validation coverage

* test(vector): add batch insert, delete, and update operation coverage

* docs: update integrity subsystem coverage to 46.4%

* docs: consolidate coverage report — clean matrix, remove boilerplate

* docs: update coverage with complete-suite numbers — 59.8% broad, 74.3% narrow

* test(storage): add URLBackend HEAD exists with 2xx coverage

* bump submodules

* fix: address PR review — curl_slist leak, ec ordering, CAS rollback, production hooks

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 22:58:15 -04:00

322 lines
11 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <yams/daemon/components/DaemonLifecycleFsm.h>
#include <yams/daemon/components/RequestDispatcher.h>
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/components/StateComponent.h>
#include <yams/daemon/ipc/message_framing.h>
#include <yams/daemon/ipc/request_context_registry.h>
#include <yams/daemon/ipc/request_handler.h>
#include <yams/metadata/connection_pool.h>
#include <yams/metadata/metadata_repository.h>
#include <boost/asio.hpp>
#include <array>
#include <atomic>
#include <chrono>
#include <memory>
#include <mutex>
#include <optional>
#include <thread>
using namespace std::chrono_literals;
using namespace yams;
using namespace yams::daemon;
namespace {
std::shared_ptr<metadata::ConnectionPool> getRepairCancelTestPool() {
static std::shared_ptr<metadata::ConnectionPool> pool = [] {
metadata::ConnectionPoolConfig cfg{};
cfg.enableWAL = false;
cfg.enableForeignKeys = false;
auto p = std::make_shared<metadata::ConnectionPool>(":memory:", cfg);
(void)p->initialize();
return p;
}();
return pool;
}
class BlockingRepairMetadataRepository final : public metadata::MetadataRepository {
public:
BlockingRepairMetadataRepository() : metadata::MetadataRepository(*getRepairCancelTestPool()) {}
Result<std::vector<metadata::DocumentInfo>>
queryDocuments(const metadata::DocumentQueryOptions&) override {
std::unique_lock<std::mutex> lk(mu_);
entered_ = true;
cv_.notify_all();
cv_.wait(lk, [&] { return released_; });
return std::vector<metadata::DocumentInfo>{};
}
bool waitUntilEntered(std::chrono::milliseconds timeout) {
std::unique_lock<std::mutex> lk(mu_);
return cv_.wait_for(lk, timeout, [&] { return entered_; });
}
void release() {
std::lock_guard<std::mutex> lk(mu_);
released_ = true;
cv_.notify_all();
}
private:
std::mutex mu_;
std::condition_variable cv_;
bool entered_{false};
bool released_{false};
};
template <typename Fn> class ScopeExit {
public:
explicit ScopeExit(Fn fn) : fn_(std::move(fn)) {}
~ScopeExit() { fn_(); }
private:
Fn fn_;
};
bool waitForCondition(std::chrono::milliseconds timeout, const std::function<bool()>& predicate) {
auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
if (predicate()) {
return true;
}
std::this_thread::sleep_for(10ms);
}
return predicate();
}
std::optional<Message> readMessageWithTimeout(boost::asio::local::stream_protocol::socket& socket,
MessageFramer& framer,
std::chrono::milliseconds timeout) {
auto waitForBytes = [&](std::size_t bytes) {
auto deadline = std::chrono::steady_clock::now() + timeout;
boost::system::error_code ec;
while (std::chrono::steady_clock::now() < deadline) {
if (socket.available(ec) >= bytes) {
return !ec;
}
if (ec) {
return false;
}
std::this_thread::sleep_for(5ms);
}
return false;
};
if (!waitForBytes(MessageFramer::HEADER_SIZE)) {
return std::nullopt;
}
std::array<uint8_t, MessageFramer::HEADER_SIZE> headerBuf{};
boost::asio::read(socket, boost::asio::buffer(headerBuf));
auto headerResult =
framer.parse_header(std::span<const uint8_t>(headerBuf.data(), headerBuf.size()));
REQUIRE(headerResult);
std::vector<uint8_t> frame(headerBuf.begin(), headerBuf.end());
const auto payloadSize = headerResult.value().payload_size;
if (payloadSize > 0) {
if (!waitForBytes(payloadSize)) {
return std::nullopt;
}
std::vector<uint8_t> payload(payloadSize);
boost::asio::read(socket, boost::asio::buffer(payload));
frame.insert(frame.end(), payload.begin(), payload.end());
}
auto messageResult = framer.parse_frame(frame);
REQUIRE(messageResult);
return messageResult.value();
}
} // namespace
TEST_CASE("RequestHandler: canceled RepairRequest streaming completes without deadlock",
"[daemon][ipc][repair][cancel]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
DaemonConfig config;
StateComponent state;
DaemonLifecycleFsm lifecycle;
ServiceManager serviceManager(config, state, lifecycle);
auto repo = std::make_shared<BlockingRepairMetadataRepository>();
serviceManager.__test_setMetadataRepo(repo);
serviceManager.startRepairService([] { return size_t{0}; });
ScopeExit stopRepair([&] { serviceManager.stopRepairService(); });
ScopeExit releaseRepo([&] { repo->release(); });
RequestDispatcher dispatcher(nullptr, &serviceManager, &state);
// io_context must outlive the RequestHandler: the handler's internal
// write_strand_exec_ holds a strand bound to io's strand_executor_service,
// and destroying io first would invalidate the strand before ~RequestHandler
// releases it.
boost::asio::io_context io;
RequestHandler::Config handlerConfig;
handlerConfig.enable_multiplexing = true;
handlerConfig.enable_streaming = true;
handlerConfig.stream_chunk_timeout = 25ms;
auto handler = std::make_shared<RequestHandler>(&dispatcher, handlerConfig);
boost::asio::local::stream_protocol::socket clientSock(io);
boost::asio::local::stream_protocol::socket serverSock(io);
boost::asio::local::connect_pair(clientSock, serverSock);
yams::compat::stop_source stopSource;
auto serverSockPtr =
std::make_shared<boost::asio::local::stream_protocol::socket>(std::move(serverSock));
boost::asio::co_spawn(
io,
[handler, serverSockPtr, token = stopSource.get_token()]() -> boost::asio::awaitable<void> {
co_await handler->handle_connection(serverSockPtr, token, 1);
co_return;
},
boost::asio::detached);
auto work = boost::asio::make_work_guard(io);
std::thread ioThread([&io]() { io.run(); });
ScopeExit stopIo([&] {
stopSource.request_stop();
boost::system::error_code ec;
clientSock.close(ec);
work.reset();
if (ioThread.joinable()) {
ioThread.join();
}
});
constexpr uint64_t kRequestId = 77;
RepairRequest repairReq;
repairReq.repairDownloads = true;
Message request;
request.version = 1;
request.requestId = kRequestId;
request.expectsStreamingResponse = true;
request.payload = Request{std::in_place_type<RepairRequest>, repairReq};
MessageFramer framer(1024 * 1024);
std::vector<uint8_t> frame;
REQUIRE(framer.frame_message_into(request, frame));
boost::asio::write(clientSock, boost::asio::buffer(frame));
auto headerMessage = readMessageWithTimeout(clientSock, framer, 2s);
REQUIRE(headerMessage.has_value());
CHECK(headerMessage->requestId == kRequestId);
REQUIRE(repo->waitUntilEntered(2s));
REQUIRE(waitForCondition(
2s, [&] { return RequestContextRegistry::instance().cancel(kRequestId); }));
bool sawCancelled = false;
const auto deadline = std::chrono::steady_clock::now() + 5s;
while (std::chrono::steady_clock::now() < deadline && !sawCancelled) {
auto message = readMessageWithTimeout(clientSock, framer, 500ms);
if (!message.has_value()) {
continue;
}
CHECK(message->requestId == kRequestId);
auto* payload = std::get_if<Response>(&message->payload);
REQUIRE(payload != nullptr);
if (auto* error = std::get_if<ErrorResponse>(payload)) {
CHECK(error->code == ErrorCode::OperationCancelled);
CHECK(error->message == "Request canceled");
sawCancelled = true;
}
}
REQUIRE(sawCancelled);
}
TEST_CASE("RequestHandler: RepairRequest streaming writes refresh connection activity",
"[daemon][ipc][repair][activity]") {
#ifdef _WIN32
SKIP("Unix domain socket tests skipped on Windows");
#endif
DaemonConfig config;
StateComponent state;
DaemonLifecycleFsm lifecycle;
ServiceManager serviceManager(config, state, lifecycle);
auto repo = std::make_shared<BlockingRepairMetadataRepository>();
serviceManager.__test_setMetadataRepo(repo);
serviceManager.startRepairService([] { return size_t{0}; });
ScopeExit stopRepair([&] { serviceManager.stopRepairService(); });
ScopeExit releaseRepo([&] { repo->release(); });
RequestDispatcher dispatcher(nullptr, &serviceManager, &state);
boost::asio::io_context io;
RequestHandler::Config handlerConfig;
handlerConfig.enable_multiplexing = true;
handlerConfig.enable_streaming = true;
handlerConfig.stream_chunk_timeout = 50ms;
auto handler = std::make_shared<RequestHandler>(&dispatcher, handlerConfig);
boost::asio::local::stream_protocol::socket clientSock(io);
boost::asio::local::stream_protocol::socket serverSock(io);
boost::asio::local::connect_pair(clientSock, serverSock);
yams::compat::stop_source stopSource;
auto serverSockPtr =
std::make_shared<boost::asio::local::stream_protocol::socket>(std::move(serverSock));
std::atomic<size_t> activityCount{0};
boost::asio::co_spawn(
io,
[handler, serverSockPtr, token = stopSource.get_token(),
&activityCount]() -> boost::asio::awaitable<void> {
co_await handler->handle_connection(serverSockPtr, token, 1, [&activityCount] {
activityCount.fetch_add(1, std::memory_order_relaxed);
});
co_return;
},
boost::asio::detached);
auto work = boost::asio::make_work_guard(io);
std::thread ioThread([&io]() { io.run(); });
ScopeExit stopIo([&] {
stopSource.request_stop();
boost::system::error_code ec;
clientSock.close(ec);
work.reset();
if (ioThread.joinable()) {
ioThread.join();
}
});
constexpr uint64_t kRequestId = 78;
RepairRequest repairReq;
repairReq.repairDownloads = true;
Message request;
request.version = 1;
request.requestId = kRequestId;
request.expectsStreamingResponse = true;
request.payload = Request{std::in_place_type<RepairRequest>, repairReq};
MessageFramer framer(1024 * 1024);
std::vector<uint8_t> frame;
REQUIRE(framer.frame_message_into(request, frame));
boost::asio::write(clientSock, boost::asio::buffer(frame));
auto headerMessage = readMessageWithTimeout(clientSock, framer, 2s);
REQUIRE(headerMessage.has_value());
CHECK(headerMessage->requestId == kRequestId);
REQUIRE(repo->waitUntilEntered(2s));
const auto baseline = activityCount.load(std::memory_order_relaxed);
REQUIRE(baseline >= 1);
REQUIRE(waitForCondition(
2s, [&] { return activityCount.load(std::memory_order_relaxed) > baseline; }));
}