feat: wire the heartbeat's health signal, and add fetchFinalizedRoot

Three fields — head.blockNumber, head.updatedAt and heartbeatFailures — were
read by statusSnapshot() and never assigned, and State::Degraded appeared only
in stateName(). The heartbeat was fire-and-forget with a comment pointing at a
pollHeartbeat() that does not exist, so nothing ever observed its outcome:
status().head stayed "" for the life of the process and a proxy whose sync had
died still reported "running".

CallSlot now carries a Kind, so the trampoline can tell a user call from a
heartbeat or a head probe. Three consecutive heartbeat failures degrade the
proxy and one success clears it; head is refreshed by a separate
eth_blockNumber probe every fifth beat, since eth_syncing answers a hardcoded
`false` and cannot report it. live() joins Running and Degraded for callers
making lifecycle decisions, leaving running() strict for health.

fetchFinalizedRoot() is new, and lives here rather than in the panel because
Basecamp sandboxes ui_qml plugins away from the network: an XMLHttpRequest from
a view is refused outright. It is a convenience, not a trust anchor, and says
so. Adds libcurl, used for that one request and nothing else.

Tests spin on the condition rather than sleeping a fixed interval — the first
draft was green on an idle machine and red under a parallel build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-26 14:51:11 -03:00
co-authored by Claude Opus 5
parent 8400bd7d36
commit a1a17acbc5
12 changed files with 519 additions and 38 deletions
+8
View File
@@ -24,6 +24,8 @@ logos_module(
src/proxy_runtime.cpp
src/rpc_http_server.h
src/rpc_http_server.cpp
src/beacon_client.h
src/beacon_client.cpp
EXTERNAL_LIBS
verifproxy
INCLUDE_DIRS
@@ -38,6 +40,12 @@ find_package(PkgConfig REQUIRED)
pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd)
target_link_libraries(verified_proxy_module_module_plugin PRIVATE PkgConfig::MHD)
# Outbound HTTP for fetchFinalizedRoot(). Only ever used for that one request:
# all verified traffic goes through the library's own chronos-based clients, so
# curl is not on any hot path.
pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl)
target_link_libraries(verified_proxy_module_module_plugin PRIVATE PkgConfig::CURL)
# Inject the module version from metadata.json so moduleVersion() reports it
# without parsing JSON at runtime.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/metadata.json" _vp_metadata_json)
+12 -1
View File
@@ -63,6 +63,7 @@ impossible without it. (Infura notably does not.)
| `start()` | Blocks until the light client initialises, bounded by `startTimeoutMs`. |
| `stop()` | Drains, then releases. See the note on `drainTimeoutMs` below — it is not a tight bound. |
| `ok()` / `status()` | Health probe and full state. `status()` never blocks on the proxy thread. |
| `fetchFinalizedRoot(beaconUrl)` | Convenience: asks a beacon node for its current finalized root. **Not** a trust anchor — see below. |
| `rpc(method, params)` | Any method the proxy supports. `params` is a JSON-RPC array. |
| `ethBlockNumber()`, `ethGetBalance(...)`, `ethCall(...)`, … | Typed wrappers over the same path. |
@@ -230,7 +231,17 @@ single `eth_blockNumber` in that run took **12.6 s**, against a 30 s default
value reaches a `quit()` before that point.
* Sync observability is limited: there is no exported getter for the
finalized/optimistic slot. `status().state == "degraded"` means "up, but
heartbeats are failing", inferred from their error strings.
heartbeats are failing", inferred from their error strings — three
consecutive failures degrade, one success clears it. `status().head` is
refreshed by a separate `eth_blockNumber` probe every fifth heartbeat,
because `eth_syncing` answers a hardcoded `false` and cannot report it.
* `fetchFinalizedRoot()` exists because Basecamp sandboxes `ui_qml` plugins
away from the network entirely — an `XMLHttpRequest` from a panel is refused
with *"sandboxed ui_qml modules may not use the network"* — so a UI that
wants to offer "fetch me a root" has to route it through a core module.
It is a convenience for getting started, **not** part of the trust model: a
root taken from the same endpoint you are about to verify against anchors
nothing. For anything holding real value, obtain the root independently.
## Development
+37 -36
View File
@@ -1,40 +1,41 @@
{
"name": "verified_proxy_module",
"display_name": "Verified Proxy",
"version": "0.1.0",
"description": "Light-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy",
"author": "Logos Core Team",
"type": "core",
"interface": "universal",
"concurrency": "multi",
"category": "wallet",
"main": "verified_proxy_module_plugin",
"codegen": {
"impl_header": "verified_proxy_impl.h",
"impl_class": "VerifiedProxyImpl"
"name": "verified_proxy_module",
"display_name": "Verified Proxy",
"version": "0.1.0",
"description": "Light-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy",
"author": "Logos Core Team",
"type": "core",
"interface": "universal",
"concurrency": "multi",
"category": "wallet",
"main": "verified_proxy_module_plugin",
"codegen": {
"impl_header": "verified_proxy_impl.h",
"impl_class": "VerifiedProxyImpl"
},
"dependencies": [],
"include": [],
"capabilities": [],
"nix": {
"packages": {
"build": [
"pkg-config"
],
"runtime": [
"nlohmann_json",
"libmicrohttpd",
"curl"
]
},
"dependencies": [],
"include": [],
"capabilities": [],
"nix": {
"packages": {
"build": [
"pkg-config"
],
"runtime": [
"nlohmann_json",
"libmicrohttpd"
]
},
"external_libraries": [
{
"name": "verifproxy"
}
],
"cmake": {
"extra_include_dirs": [
"lib"
]
}
"external_libraries": [
{
"name": "verifproxy"
}
],
"cmake": {
"extra_include_dirs": [
"lib"
]
}
}
}
+102
View File
@@ -0,0 +1,102 @@
#include "beacon_client.h"
#include <curl/curl.h>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
namespace beacon_client {
std::string trim(const std::string& s) {
const auto b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
bool isHttpUrl(const std::string& url) {
return url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0;
}
std::string finalizedHeaderUrl(const std::string& base) {
std::string b = base;
while (!b.empty() && b.back() == '/') b.pop_back();
return b + "/eth/v1/beacon/headers/finalized";
}
namespace {
size_t appendBody(char* ptr, size_t size, size_t nmemb, void* userdata) {
const size_t n = size * nmemb;
static_cast<std::string*>(userdata)->append(ptr, n);
return n;
}
// curl_global_init is not thread-safe, and every curl_easy_init after the
// first would otherwise race it. One init per process, on first use.
void ensureGlobalInit() {
static const bool once = [] {
curl_global_init(CURL_GLOBAL_DEFAULT);
return true;
}();
(void)once;
}
} // namespace
HttpResponse httpGet(const std::string& url, long timeoutSeconds) {
ensureGlobalInit();
HttpResponse out;
CURL* curl = curl_easy_init();
if (!curl) {
out.error = "could not initialise HTTP client";
return out;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, appendBody);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out.body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeoutSeconds);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
// A beacon node redirecting us to a different scheme is not a redirect we
// want to follow silently.
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_USERAGENT, "logos-verified-proxy-module");
const CURLcode rc = curl_easy_perform(curl);
if (rc != CURLE_OK) {
out.error = curl_easy_strerror(rc);
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status);
}
curl_easy_cleanup(curl);
return out;
}
std::string parseFinalizedRoot(const std::string& body, std::string& slotOut) {
slotOut.clear();
json doc = json::parse(body, nullptr, /*allow_exceptions=*/false);
if (doc.is_discarded() || !doc.is_object()) return {};
const auto data = doc.find("data");
if (data == doc.end() || !data->is_object()) return {};
const auto root = data->find("root");
if (root == data->end() || !root->is_string()) return {};
// The slot is nested two levels further down and is purely informational,
// so a missing one is not a failure.
const auto header = data->find("header");
if (header != data->end() && header->is_object()) {
const auto message = header->find("message");
if (message != header->end() && message->is_object()) {
const auto slot = message->find("slot");
if (slot != message->end() && slot->is_string()) slotOut = slot->get<std::string>();
}
}
return root->get<std::string>();
}
} // namespace beacon_client
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <string>
// A single-purpose HTTP client for one job: asking a beacon node for its
// current finalized block root, so an operator has a trusted-root candidate to
// paste into configure().
//
// This lives in the module rather than in the UI because Basecamp sandboxes
// ui_qml plugins away from the network — a core module is the only component
// permitted to make the request. See fetchFinalizedRoot() in the impl header
// for why this is a convenience and not part of the trust model.
namespace beacon_client {
// Strip surrounding whitespace.
std::string trim(const std::string& s);
// True when `url` is http:// or https://. Deliberately stricter than the
// config's own URL check, which also admits ws:// and wss:// — those are valid
// beacon transports for the library but cannot serve a REST GET.
bool isHttpUrl(const std::string& url);
// Join `base` with the finalized-headers path, tolerating a trailing slash.
// Pure and side-effect free so the URL shape is unit-testable without a server.
std::string finalizedHeaderUrl(const std::string& base);
struct HttpResponse {
long status = 0; // 0 when the request never completed
std::string body;
std::string error; // transport-level failure, empty on success
};
// Blocking GET. Called from the module's own dispatch thread, never from the
// proxy thread — a stalled beacon node must not be able to hold up the pump.
HttpResponse httpGet(const std::string& url, long timeoutSeconds = 15);
// Pull `data.root` out of a beacon headers response. Returns "" when the
// payload is not the expected shape.
std::string parseFinalizedRoot(const std::string& body, std::string& slotOut);
} // namespace beacon_client
+84
View File
@@ -4,6 +4,8 @@
#include <chrono>
#include <cstdio>
#include <ctime>
#include <ios>
#include <sstream>
#include <utility>
extern "C" {
@@ -211,6 +213,15 @@ void ProxyRuntime::threadMain() {
if (m_inFlight.load() == 0 && keepAliveEnabled()
&& steady_clock::now() >= nextKeepAlive) {
// Every Nth beat, ask for the head as well. The heartbeat itself
// cannot report it — eth_syncing answers a hardcoded `false` — and
// probing on every beat would multiply execution-backend traffic
// for a number that only has to be roughly current.
static constexpr int64_t kHeadEveryBeats = 5;
if (m_beatsSinceHead.fetch_add(1, std::memory_order_relaxed) + 1 >= kHeadEveryBeats) {
m_beatsSinceHead.store(0, std::memory_order_relaxed);
issueHeadProbe();
}
issueKeepAlive();
nextKeepAlive = steady_clock::now() + milliseconds(m_cfg.keepAliveIntervalMs);
}
@@ -366,6 +377,11 @@ void ProxyRuntime::callbackTrampoline(Context*, int status, char* result, void*
}
slot->cv.notify_all();
}
switch (slot->kind) {
case CallSlot::Kind::Heartbeat: box->rt->noteHeartbeat(*slot); break;
case CallSlot::Kind::HeadProbe: box->rt->noteHeadProbe(*slot); break;
case CallSlot::Kind::User: break;
}
box->rt->noteFinished(slot->id, status == RET_SUCCESS);
} catch (...) {
// Never propagate into Nim.
@@ -425,6 +441,7 @@ void ProxyRuntime::issueKeepAlive() {
slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed);
slot->method = "eth_syncing";
slot->params = "[]";
slot->kind = CallSlot::Kind::Heartbeat;
auto* box = new CallBox{ slot, this };
m_inFlight.fetch_add(1, std::memory_order_acq_rel);
@@ -437,6 +454,73 @@ void ProxyRuntime::issueKeepAlive() {
m_pending.push_back(slot);
}
// The heartbeat's own return value is a hardcoded `false` and tells us nothing
// about the head, so a separate, less frequent probe asks for the block number
// outright. Same fire-and-forget shape; observed in noteHeadProbe().
void ProxyRuntime::issueHeadProbe() {
assert(std::this_thread::get_id() == m_threadId);
auto slot = std::make_shared<CallSlot>();
slot->id = m_nextId.fetch_add(1, std::memory_order_relaxed);
slot->method = "eth_blockNumber";
slot->params = "[]";
slot->kind = CallSlot::Kind::HeadProbe;
auto* box = new CallBox{ slot, this };
m_inFlight.fetch_add(1, std::memory_order_acq_rel);
::proxyCall(m_ctx, slot->method.data(), slot->params.data(),
&ProxyRuntime::callbackTrampoline, box);
std::lock_guard<std::mutex> lk(m_mu);
m_pending.push_back(slot);
}
// Consecutive heartbeat failures are the only sync-health signal the C ABI
// offers: the error STRING is machine-readable ("UnavailableDataError: trusted
// block root not set", "VerificationError: unviable fork"), the return value is
// not. Three in a row is deliberately more than one blip and less than a long
// outage.
void ProxyRuntime::noteHeartbeat(const CallSlot& slot) {
static constexpr int64_t kDegradeAfter = 3;
if (slot.status == RET_SUCCESS) {
m_heartbeatStreak.store(0, std::memory_order_relaxed);
// Only climb back out of Degraded — never overwrite Draining/Stopped,
// which a concurrent stop() may have just set.
if (m_state.load() == State::Degraded) setState(State::Running);
return;
}
m_heartbeatFailures.fetch_add(1, std::memory_order_relaxed);
const int64_t streak = m_heartbeatStreak.fetch_add(1, std::memory_order_relaxed) + 1;
if (streak >= kDegradeAfter && m_state.load() == State::Running)
setState(State::Degraded, errorMessage(slot.status, slot.result));
}
void ProxyRuntime::noteHeadProbe(const CallSlot& slot) {
if (slot.status != RET_SUCCESS) return;
bool wasJson = false;
const json v = decodePayload(slot.result, wasJson);
// Upstream answers eth_blockNumber with a bare JSON NUMBER, not the hex
// string a JSON-RPC client would expect. Normalise to the documented
// "0x…" shape here so status() has one form.
std::string hex;
if (v.is_number_unsigned()) {
std::ostringstream o;
o << "0x" << std::hex << v.get<uint64_t>();
hex = o.str();
} else if (v.is_string()) {
hex = v.get<std::string>();
}
if (hex.empty()) return;
std::lock_guard<std::mutex> lk(m_errMu);
m_headBlockNumber = hex;
m_headUpdatedAt = nowSeconds();
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
+22
View File
@@ -37,6 +37,13 @@ struct CallSlot {
// the Nim side copies its cstring arguments before its first await.
std::string method;
std::string params;
// What the completion means. A User call has a waiter blocked on `cv`;
// the other two are fire-and-forget and are the ONLY way the runtime
// learns anything about proxy health, since the library exposes no getter
// for light-client progress.
enum class Kind { User, Heartbeat, HeadProbe };
Kind kind = Kind::User;
};
class ProxyRuntime {
@@ -61,6 +68,14 @@ public:
bool running() const { return m_state.load() == State::Running; }
/// Running OR degraded. Degraded means the proxy is up but its heartbeat
/// is failing, so it is still a stoppable, live process — lifecycle
/// decisions want this, health checks want running().
bool live() const {
const State s = m_state.load();
return s == State::Running || s == State::Degraded;
}
/// THE call path. Everything — the ~60 typed wrappers and the generic
/// rpc() — funnels through `proxyCall`, which is a string `case` over the
/// same exported procs the typed C entry points call.
@@ -98,6 +113,9 @@ private:
/// C callback. Runs on the proxy thread; must never let an exception
/// escape into Nim frames.
static void callbackTrampoline(Context* ctx, int status, char* result, void* userData);
void issueHeadProbe();
void noteHeartbeat(const CallSlot& slot);
void noteHeadProbe(const CallSlot& slot);
void noteFinished(uint64_t id, bool ok);
void recordPump(int64_t ms, bool busy);
@@ -117,6 +135,10 @@ private:
std::atomic<int64_t> m_callsTotal{0};
std::atomic<int64_t> m_callsFailed{0};
std::atomic<int64_t> m_heartbeatFailures{0};
// Consecutive failures, not the lifetime total: one blip must not latch
// the proxy into degraded forever.
std::atomic<int64_t> m_heartbeatStreak{0};
std::atomic<int64_t> m_beatsSinceHead{0};
std::atomic<int64_t> m_pumpCalls{0};
std::atomic<int64_t> m_pumpMaxMs{0};
std::atomic<int64_t> m_pumpIdle[kPumpBuckets]{};
+25
View File
@@ -6,6 +6,7 @@
#include <sstream>
#include <system_error>
#include "beacon_client.h"
#include "proxy_config.h"
#include "proxy_runtime.h"
#include "rpc_http_server.h"
@@ -188,6 +189,30 @@ LogosMap VerifiedProxyImpl::status() {
std::string VerifiedProxyImpl::moduleVersion() { return VERIFIED_PROXY_MODULE_VERSION; }
std::string VerifiedProxyImpl::libraryVersion() { return VERIFIED_PROXY_NIMBUS_REV; }
StdLogosResult VerifiedProxyImpl::fetchFinalizedRoot(const std::string& beaconUrl) {
const std::string base = beacon_client::trim(beaconUrl);
if (base.empty()) return { false, {}, "beacon URL is required" };
if (!beacon_client::isHttpUrl(base))
return { false, {}, "beacon URL must be http(s): " + base };
const std::string url = beacon_client::finalizedHeaderUrl(base);
const beacon_client::HttpResponse res = beacon_client::httpGet(url);
if (!res.error.empty()) return { false, {}, "beacon request failed: " + res.error };
if (res.status != 200)
return { false, {}, "beacon returned HTTP " + std::to_string(res.status) };
std::string slot;
const std::string root = beacon_client::parseFinalizedRoot(res.body, slot);
if (root.empty())
return { false, {}, "beacon response did not contain data.root" };
LogosMap out;
out["root"] = root;
out["slot"] = slot;
out["source"] = url;
return { true, out };
}
// ── Verified JSON-RPC ───────────────────────────────────────────────────────
//
// Every one of these is three lines over the same dispatch path: the library's
+16
View File
@@ -128,6 +128,22 @@ public:
/// path the typed methods use.
std::string localEndpoint();
/// Fetch the current finalized beacon block root from `beaconUrl`.
///
/// A convenience for operators who have no root to hand: it queries
/// `<beaconUrl>/eth/v1/beacon/headers/finalized` and returns
/// `{"root": "0x…", "slot": "…"}`. Takes the URL as an argument rather
/// than reading the stored config so it is usable before configure().
///
/// This is deliberately NOT part of the trust model. A root fetched from
/// the same endpoint you are about to distrust anchors nothing — it is a
/// starting point for testing, and an operator running against real value
/// should take the root from a source they independently trust and paste
/// it in. The method lives here rather than in a UI because Basecamp
/// sandboxes `ui_qml` plugins away from the network entirely; a core
/// module is the only component allowed to make the request.
StdLogosResult fetchFinalizedRoot(const std::string& beaconUrl);
// ── Verified JSON-RPC ────────────────────────────────────────────────
/// Any method the proxy supports, dispatched through the library's own
+4 -1
View File
@@ -5,6 +5,7 @@ include(LogosTest)
find_package(PkgConfig REQUIRED)
pkg_check_modules(MHD REQUIRED IMPORTED_TARGET libmicrohttpd)
pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl)
# Mirror the module build: inject the version from metadata.json.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/../metadata.json" _vp_metadata_json)
@@ -22,11 +23,13 @@ logos_test(
../src/proxy_runtime.cpp
../src/verified_proxy_impl.cpp
../src/rpc_http_server.cpp
../src/beacon_client.cpp
TEST_SOURCES
main.cpp
test_config_validation.cpp
test_proxy_runtime.cpp
test_rpc_http_server.cpp
test_beacon_client.cpp
verified_proxy_events_test.cpp
MOCK_C_SOURCES
mocks/mock_libverifproxy.cpp
@@ -34,6 +37,6 @@ logos_test(
stubs
.
)
target_link_libraries(verified_proxy_module_tests PRIVATE PkgConfig::MHD)
target_link_libraries(verified_proxy_module_tests PRIVATE PkgConfig::MHD PkgConfig::CURL)
target_compile_definitions(verified_proxy_module_tests PRIVATE
VERIFIED_PROXY_MODULE_VERSION="${VERIFIED_PROXY_MODULE_VERSION}")
+73
View File
@@ -0,0 +1,73 @@
// URL construction and response parsing for the finalized-root helper.
//
// Both halves are pure, so the whole shape of the request and the whole shape
// of what we accept back are testable without a beacon node or a socket.
#include <string>
#include <logos_test.h>
#include "beacon_client.h"
namespace bc = beacon_client;
LOGOS_TEST(beacon_url_appends_the_finalized_headers_path) {
LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org"),
"https://example.org/eth/v1/beacon/headers/finalized");
}
LOGOS_TEST(beacon_url_tolerates_trailing_slashes) {
// A URL pasted from a browser very often carries one.
LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org///"),
"https://example.org/eth/v1/beacon/headers/finalized");
}
LOGOS_TEST(beacon_url_keeps_a_path_prefix) {
LOGOS_ASSERT_EQ(bc::finalizedHeaderUrl("https://example.org/beacon"),
"https://example.org/beacon/eth/v1/beacon/headers/finalized");
}
LOGOS_TEST(beacon_accepts_only_http_schemes) {
// ws:// and wss:// are valid beacon transports for the library itself, but
// cannot serve the REST GET this helper makes.
LOGOS_ASSERT_TRUE(bc::isHttpUrl("http://example.org"));
LOGOS_ASSERT_TRUE(bc::isHttpUrl("https://example.org"));
LOGOS_ASSERT_FALSE(bc::isHttpUrl("wss://example.org"));
LOGOS_ASSERT_FALSE(bc::isHttpUrl("ws://example.org"));
LOGOS_ASSERT_FALSE(bc::isHttpUrl("file:///etc/passwd"));
LOGOS_ASSERT_FALSE(bc::isHttpUrl(""));
}
LOGOS_TEST(beacon_trims_surrounding_whitespace) {
LOGOS_ASSERT_EQ(bc::trim(" https://example.org \n"), "https://example.org");
LOGOS_ASSERT_EQ(bc::trim(" "), "");
}
LOGOS_TEST(beacon_parses_root_and_slot) {
const std::string body = R"({
"data": {
"root": "0xabc",
"header": { "message": { "slot": "12345" } }
}
})";
std::string slot;
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(body, slot), "0xabc");
LOGOS_ASSERT_EQ(slot, "12345");
}
LOGOS_TEST(beacon_parse_tolerates_a_missing_slot) {
// The slot is informational; only the root is load-bearing.
std::string slot = "stale";
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{"root":"0xdef"}})", slot), "0xdef");
LOGOS_ASSERT_EQ(slot, "");
}
LOGOS_TEST(beacon_parse_rejects_malformed_payloads) {
std::string slot;
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("not json", slot), "");
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("[]", slot), "");
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot("{}", slot), "");
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{}})", slot), "");
// A non-string root is the shape an error page could plausibly take.
LOGOS_ASSERT_EQ(bc::parseFinalizedRoot(R"({"data":{"root":42}})", slot), "");
}
+95
View File
@@ -49,6 +49,20 @@ int lastIndexOf(const std::vector<std::string>& v, const std::string& s) {
return -1;
}
/// Spin until `pred` holds or `budgetMs` elapses. Sleeping a fixed interval and
/// hoping N heartbeats fit inside it makes a test that is green on an idle
/// machine and red under a parallel nix build; this makes a loaded builder
/// slower rather than flaky.
template <typename Pred>
bool spinUntil(Pred pred, int budgetMs = 8000) {
const auto deadline = steady_clock::now() + milliseconds(budgetMs);
while (steady_clock::now() < deadline) {
if (pred()) return true;
std::this_thread::sleep_for(milliseconds(5));
}
return pred();
}
} // namespace
LOGOS_TEST(runtime_start_and_stop_round_trip) {
@@ -383,3 +397,84 @@ LOGOS_TEST(runtime_heartbeat_issues_eth_syncing_only_when_enabled) {
LOGOS_ASSERT_EQ(t.cFunctionCallCount("proxyCall:eth_syncing"), 0);
}
}
LOGOS_TEST(runtime_head_probe_records_the_block_number) {
// The heartbeat cannot report the head — upstream's eth_syncing answers a
// hardcoded `false` — so a separate eth_blockNumber probe populates it.
// Before this was wired, status().head.blockNumber was a field that was
// read and never assigned, so it stayed "" for the life of the process.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall").returns("11572348"); // a bare JSON number
ProxyConfig cfg = testConfig();
cfg.keepAlive = "interval";
cfg.keepAliveIntervalMs = 20;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
const bool got = spinUntil([&] {
return !rt.statusSnapshot()["head"]["blockNumber"].get<std::string>().empty();
});
const json s = rt.statusSnapshot();
rt.stop();
LOGOS_ASSERT_TRUE(got);
LOGOS_ASSERT_GT(t.cFunctionCallCount("proxyCall:eth_blockNumber"), 0);
// Normalised to the "0x…" form status() documents, not the bare number
// upstream returns. 11572348 == 0xb0947c.
LOGOS_ASSERT_EQ(s["head"]["blockNumber"].get<std::string>(), std::string("0xb0947c"));
LOGOS_ASSERT_GT(s["head"]["updatedAt"].get<int64_t>(), 0);
}
LOGOS_TEST(runtime_consecutive_heartbeat_failures_degrade_the_proxy) {
// The error string of a failing heartbeat is the only machine-readable
// sync-health signal the C ABI exposes. Three in a row is the threshold —
// more than a blip, less than an outage.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
t.mockCFunction("proxyCall_status").returns(RET_ERROR);
ProxyConfig cfg = testConfig();
cfg.keepAlive = "interval";
cfg.keepAliveIntervalMs = 20;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
const bool degraded = spinUntil([&] {
return rt.statusSnapshot()["state"].get<std::string>() == "degraded";
});
const json s = rt.statusSnapshot();
LOGOS_ASSERT_TRUE(degraded);
LOGOS_ASSERT_EQ(s["state"].get<std::string>(), std::string("degraded"));
LOGOS_ASSERT_GE(s["counters"]["heartbeatFailures"].get<int64_t>(), 3);
// Degraded is not running — ok() must report unhealthy...
LOGOS_ASSERT_FALSE(rt.running());
// ...but the proxy is still a live, stoppable process.
LOGOS_ASSERT_TRUE(rt.live());
rt.stop();
}
LOGOS_TEST(runtime_a_healthy_heartbeat_leaves_state_running) {
// The mirror of the test above: the streak must not latch. A proxy whose
// heartbeats succeed stays Running no matter how many beats elapse.
auto t = LogosTestContext("verified_proxy_module");
mockReset();
ProxyConfig cfg = testConfig();
cfg.keepAlive = "interval";
cfg.keepAliveIntervalMs = 20;
ProxyRuntime rt(nullptr);
LOGOS_ASSERT_TRUE(rt.start(cfg).success);
// Wait for real beats rather than a fixed nap, so "still running" is a
// statement about many successful heartbeats and not about a short sleep.
const bool beat = spinUntil([&] { return t.cFunctionCallCount("proxyCall:eth_syncing") >= 5; });
const json s = rt.statusSnapshot();
rt.stop();
LOGOS_ASSERT_TRUE(beat);
LOGOS_ASSERT_EQ(s["state"].get<std::string>(), std::string("running"));
LOGOS_ASSERT_EQ(s["counters"]["heartbeatFailures"].get<int64_t>(), 0);
}