core: add util layer — Result, Error taxonomy, bytes, event bus, pool, log

util/ carries no wire surface, so it lands before the contract freeze.

- error: enum class Error, the engine-wide failure taxonomy; is_retryable
  enumerates every value (no default:) so -Wswitch forces the retry
  decision on each future addition. ErrorInfo carries context/http_status.
- result: Result<T> over std::expected<T, ErrorInfo>, Result<void>,
  VDM_TRY / VDM_TRY_ASSIGN. Errors returned, never thrown, on the
  transfer path.
- bytes: span aliases, LE load_le/store_le (debug-asserted precondition,
  not input validation), and a bounds-checked latching ByteReader for the
  .veloxpart.meta reader.
- event_bus: typed thread-safe pub/sub; header states plainly that
  unsubscribe is not a quiesce point and download_task will need its own
  drain.
- thread_pool: std::jthread pool; dtor joins in the body before members
  die (fixed a use-after-destruction on cv_/mu_). Header notes shutdown is
  drain-only and DAEMON will need a cancel mode.
- log: sink interface (core does no I/O); DAEMON installs one.

Tested: -Werror clean, 6 binaries green under plain / ASan+UBSan / TSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
This commit is contained in:
2026-09-09 19:03:11 +04:00
co-authored by Claude Sonnet 5
parent 5ac74ecbd9
commit ddf36e848a
17 changed files with 1401 additions and 0 deletions
View File
+96
View File
@@ -0,0 +1,96 @@
// vdm/util/error.cpp
#include "vdm/util/error.hpp"
namespace vdm {
std::string_view error_name(Error e) noexcept {
switch (e) {
case Error::ok: return "ok";
case Error::canceled: return "canceled";
case Error::resolve_failed: return "resolve_failed";
case Error::connect_failed: return "connect_failed";
case Error::tls_failed: return "tls_failed";
case Error::connection_reset: return "connection_reset";
case Error::timeout: return "timeout";
case Error::too_many_redirects: return "too_many_redirects";
case Error::http_client_error: return "http_client_error";
case Error::http_server_error: return "http_server_error";
case Error::auth_required: return "auth_required";
case Error::forbidden: return "forbidden";
case Error::not_found: return "not_found";
case Error::range_not_satisfiable: return "range_not_satisfiable";
case Error::gone: return "gone";
case Error::server_file_changed: return "server_file_changed";
case Error::content_length_mismatch: return "content_length_mismatch";
case Error::checksum_mismatch: return "checksum_mismatch";
case Error::disk_full: return "disk_full";
case Error::io_error: return "io_error";
case Error::path_rejected: return "path_rejected";
case Error::permission_denied: return "permission_denied";
case Error::meta_corrupt: return "meta_corrupt";
case Error::meta_version_unsupported: return "meta_version_unsupported";
case Error::probe_failed: return "probe_failed";
case Error::unsupported_url_scheme: return "unsupported_url_scheme";
case Error::max_retries_exhausted: return "max_retries_exhausted";
case Error::internal: return "internal";
}
return "unknown";
}
bool is_retryable(Error e) noexcept {
// Every value is listed on purpose: no `default:`, so adding an Error without a
// -Werror -Wswitch diagnostic is impossible and the retry decision is forced.
switch (e) {
// retryable
case Error::resolve_failed:
case Error::connect_failed:
case Error::connection_reset:
case Error::timeout:
case Error::http_server_error:
case Error::range_not_satisfiable: // stale metadata → re-probe then retry
case Error::content_length_mismatch:
return true;
// not retryable
case Error::ok:
case Error::canceled:
case Error::tls_failed:
case Error::too_many_redirects:
case Error::http_client_error:
case Error::auth_required: // resolved by credentials, not a retry
case Error::forbidden:
case Error::not_found:
case Error::gone:
case Error::server_file_changed: // needs a user decision
case Error::checksum_mismatch:
case Error::disk_full:
case Error::io_error:
case Error::path_rejected:
case Error::permission_denied:
case Error::meta_corrupt:
case Error::meta_version_unsupported:
case Error::probe_failed:
case Error::unsupported_url_scheme:
case Error::max_retries_exhausted:
case Error::internal:
return false;
}
return false; // unreachable; silences -Wreturn-type for an out-of-range value
}
std::string ErrorInfo::to_string() const {
std::string out(name());
if (!context.empty()) {
out += ": ";
out += context;
}
if (http_status != 0) {
out += " (HTTP ";
out += std::to_string(http_status);
out += ')';
}
return out;
}
} // namespace vdm
+53
View File
@@ -0,0 +1,53 @@
// vdm/util/log.cpp
#include "vdm/util/log.hpp"
#include <atomic>
#include <mutex>
namespace vdm {
namespace {
// Guards assignment; reads take a copy of the shared_ptr under the same lock so a
// concurrent set_log_sink() can't free the sink mid-write.
std::mutex g_mu;
std::shared_ptr<LogSink> g_sink;
} // namespace
std::string_view log_level_name(LogLevel l) noexcept {
switch (l) {
case LogLevel::trace: return "trace";
case LogLevel::debug: return "debug";
case LogLevel::info: return "info";
case LogLevel::warn: return "warn";
case LogLevel::error: return "error";
}
return "?";
}
void set_log_sink(std::shared_ptr<LogSink> sink) {
std::lock_guard lk(g_mu);
g_sink = std::move(sink);
}
std::shared_ptr<LogSink> log_sink() {
std::lock_guard lk(g_mu);
return g_sink;
}
namespace detail {
bool log_wants(LogLevel level) {
auto s = log_sink();
return s && s->enabled(level);
}
} // namespace detail
void log_emit(LogLevel level, std::string_view category, std::string message) {
auto s = log_sink();
if (!s || !s->enabled(level))
return;
s->write(LogRecord{level, category, std::move(message)});
}
} // namespace vdm
+49
View File
@@ -0,0 +1,49 @@
// vdm/util/thread_pool.cpp
#include "vdm/util/thread_pool.hpp"
namespace vdm {
ThreadPool::ThreadPool(std::size_t threads) {
if (threads == 0) {
threads = std::thread::hardware_concurrency();
if (threads == 0)
threads = 1;
}
workers_.reserve(threads);
for (std::size_t i = 0; i < threads; ++i)
workers_.emplace_back([this] { worker_loop(); });
}
ThreadPool::~ThreadPool() {
{
std::lock_guard lk(mu_);
stopping_ = true;
}
cv_.notify_all();
// Join here, in the destructor body, while mu_/cv_/jobs_ are still alive. Relying on
// std::jthread's implicit join would run it during member destruction — after cv_ and
// mu_ are already gone, which the workers are still touching.
for (auto &w : workers_)
w.join();
workers_.clear();
}
void ThreadPool::worker_loop() {
for (;;) {
std::function<void()> job;
{
std::unique_lock lk(mu_);
cv_.wait(lk, [this] { return stopping_ || !jobs_.empty(); });
if (jobs_.empty()) {
// Only reached when stopping_ and nothing left to do.
return;
}
job = std::move(jobs_.front());
jobs_.pop();
}
job();
}
}
} // namespace vdm