* fix(memory-sync): keep replication responsive Skip poisoned blobs without wedging backfill and publish immutable status snapshots so daemon IPC does not block on an active reconciliation cycle. Reconstructed from ff62d3f8, 8e232721, and e60f513d. Signed-off-by: trvon <git@trevon.dev> * feat(p2p): add authenticated direct transport Require TLS 1.3 mutual identity and bind the authenticated certificate, node, corpus, and epoch before replication state is exchanged. Reconstructed from 96f9db88 and c46de77b. Signed-off-by: trvon <git@trevon.dev> * feat(p2p): exchange causal deltas Persist peer pins and causal resume state before visibility so reconnects continue bounded authenticated replication without becoming remote query proxies. Reconstructed from 1b8be146 and 41883338. Signed-off-by: trvon <git@trevon.dev> * feat(p2p): integrate direct sync services Wire listener, reconnect, IPC, CLI, peer persistence, and daemon lifecycle around the authenticated direct transport. Reconstructed from a19aaa44. Signed-off-by: trvon <git@trevon.dev> * fix(p2p): apply replicated document state Index authenticated remote winners, propagate document tombstones, and keep service/test wiring complete across daemon paths. Reconstructed from 4ca660c6, 443253f1, 3e9db24d, and 4b2ddcc5. Signed-off-by: trvon <git@trevon.dev> * test(p2p): register direct sync gates Replace the superseded internal contract with operator-facing direct-sync guidance and register daemon integration coverage in the standard test layout. Reconstructed from 360b9115 and 89416ccb. Signed-off-by: trvon <git@trevon.dev> * fix(p2p): require bounded peer enrollment Bound aggregate sessions and worker lifetimes, create owner-only identity keys atomically, and reject unknown inbound peers until operators pin their mTLS identity. Reconstructed from f91be528, 79de8e48, 4fb1e4b8, dc30a6c0, and b329edd3. Signed-off-by: trvon <git@trevon.dev> * fix(p2p): persist quarantine and deletes Persist full-history writer commitments before invalidating forked state, and order local deletion through a durable authenticated outbox so tombstones survive crashes and reconnects. Reconstructed from 74ce01e0 and 19888604. Signed-off-by: trvon <git@trevon.dev> * feat(p2p): add authenticated cold bootstrap Install bounded zero-state snapshots only from previously operator-pinned peers, bind them to exact frontiers and full-history commitments, and recover staged journals before replication can resume. Reconstructed from 8a9f5acf. Signed-off-by: trvon <git@trevon.dev> --------- Signed-off-by: trvon <git@trevon.dev>
323 lines
11 KiB
C++
323 lines
11 KiB
C++
// pi-lens-ignore: fatal error
|
|
#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; }));
|
|
}
|