diff --git a/CMakeLists.txt b/CMakeLists.txt index f640e996..b89b96ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,6 +61,7 @@ option(BUILD_BENCHMARK "Build the EFI benchmark application" OFF) option(BUILD_BINARY "Build the EFI application" ON) option(USE_SANITIZER "Build test with sanitizer support (clang only)" ON) option(LITE_WASM_SC "Enable testnet-lite-RAM Wasm smart contracts" OFF) +option(ANT_WALKER "Build the ant-colony walker sidecar and its node client" ON) set(QUBIC_STANDALONE_RELEASE ON CACHE INTERNAL "Standalone builds are the only supported configuration" FORCE) set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static dependencies" FORCE) set(OPENSSL_USE_STATIC_LIBS TRUE CACHE BOOL "Use static OpenSSL" FORCE) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index eb4cb27c..7cf11416 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -395,6 +395,10 @@ if(IS_CLANG) endif() target_link_libraries(qubic-cli PRIVATE m) +if(ANT_WALKER) + target_compile_definitions(Qubic PRIVATE ANT_WALKER) +endif() + # Configure linker settings based on compiler if(IS_MSVC) if(NOT LITE_WASM_SC) diff --git a/src/extensions/ant_colony_maintenance.h b/src/extensions/ant_colony_maintenance.h new file mode 100644 index 00000000..e2073bfb --- /dev/null +++ b/src/extensions/ant_colony_maintenance.h @@ -0,0 +1,43 @@ +#pragma once + +// Colony upkeep outside consensus. Pure functions of a colony, so they are testable without a node. + +namespace AntColonyMaintenance +{ +// fork() clones only the calling thread, so a promoted child can inherit a claim with no owner and +// ensureAntRecordAnn's waiter would spin on it forever. +inline unsigned int releaseInheritedClaims(AntColonyBpp9000T& colony) +{ + unsigned int released = 0; + const unsigned int recordCount = colony.solutionCount(); + for (unsigned int index = 0; index < recordCount; index++) + { + if (colony.isAnnClaimHeld(index)) + { + colony.releaseAnnClaim(index); + released++; + } + } + return released; +} + +// A rebuild starts from the parent's network, so a record whose parent has none cannot be taken. +inline bool isRebuildableNow(AntColonyBpp9000T& colony, unsigned int index) +{ + if (colony.isAnnMaterialised(index) || colony.isAnnClaimHeld(index)) + { + return false; + } + const AntSolutionRecord* record = colony.recordAt(index); + if (record == nullptr) + { + return false; + } + if (record->parentRef.isRoot()) + { + return true; + } + const long long parentIndex = colony.findIndexBySolutionRef(record->parentRef); + return parentIndex != ANT_INVALID_INDEX && colony.isAnnMaterialised((unsigned int)parentIndex); +} +} diff --git a/src/extensions/ant_walker_client.h b/src/extensions/ant_walker_client.h new file mode 100644 index 00000000..b4dd2248 --- /dev/null +++ b/src/extensions/ant_walker_client.h @@ -0,0 +1,1000 @@ +#pragma once + +// Node side of the ant walker: picks records committed without a network, sends the walk to a +// separate process, publishes the result after re-verifying it against the record. + +#if defined(ANT_WALKER) && !defined(_WIN32) + +#include "ant_walker_proto.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace AntWalker +{ +enum class LinkState +{ + Disabled, + Disconnected, + Connecting, + Handshaking, + Ready, +}; + +// A wrong walker fails every job, and marking each would poison good records permanently, so a +// streak is read as a broken walker instead. +static constexpr unsigned int DISAGREEMENT_STREAK_LIMIT = 3; +static constexpr unsigned int DEADLINE_STREAK_LIMIT = 3; +// "Gone", not "slow": a walk runs for minutes and the walker answers PING throughout. +static constexpr long long MIN_JOB_DEADLINE_MS = 600'000; +static constexpr long long PING_INTERVAL_MS = 10'000; +static constexpr long long BACKOFF_MIN_MS = 100; +static constexpr long long BACKOFF_MAX_MS = 30'000; +static constexpr int POLL_SLICE_MS = 100; +static constexpr long long HEARTBEAT_INTERVAL_MS = 60'000; +// Covers a legitimate 512 MB pool derive without hiding a wedged walker. +static constexpr long long HANDSHAKE_DEADLINE_MS = 120'000; +// A record the tick is blocked on is handed back, but only once the job is old enough to be stuck: +// yanking a healthy walk throws its progress away and the node pays the whole walk again. +static constexpr long long PREEMPT_MIN_AGE_MS = 180'000; +static constexpr long long PREEMPT_ACK_WAIT_MS = 500; +static constexpr unsigned int PREEMPT_NONE = 0xFFFFFFFFu; + +struct InFlight +{ + unsigned long long jobId; + unsigned int recordIndex; + long long sentAtMs; + // Kept for the replay key: re-resolving the anchor later could pick a different digest. + m256i anchorDigest; +}; + +struct State +{ + std::string socketPath; + unsigned int threadCount = 0; + bool debug = false; + + std::atomic link{ (int)LinkState::Disabled }; + std::atomic fd{ -1 }; + std::atomic stopping{ false }; + std::atomic quiesceRequested{ false }; + std::atomic quiesceAcknowledged{ true }; + std::atomic seedGeneration{ 0 }; + std::atomic walkerPid{ -1 }; + std::atomic suspectWalkerPid{ -1 }; + std::atomic preemptRequest{ PREEMPT_NONE }; + std::atomic handedBack{ PREEMPT_NONE }; + + std::atomic jobsSent{ 0 }; + std::atomic materialised{ 0 }; + std::atomic memoHits{ 0 }; + std::atomic preempted{ 0 }; + std::atomic disagreements{ 0 }; + std::atomic deadlineExpiries{ 0 }; + std::atomic staleDropped{ 0 }; + std::atomic reconnects{ 0 }; + std::atomic failedCount{ 0 }; + std::atomic walkMsEma{ 0 }; + std::atomic lastResultAtMs{ 0 }; + std::atomic inFlightCount{ 0 }; + std::atomic backlog{ 0 }; + + std::vector failedBits; + std::vector rolledBackCandidates; + std::vector inFlight; + unsigned long long nextJobId = 1; + unsigned int cursor = 0; + unsigned int disagreementStreak = 0; + unsigned int deadlineStreak = 0; + unsigned int helloGeneration = 0; + long long handshakeStartedAtMs = 0; + + std::thread* dispatcher = nullptr; +}; + +inline State gState; + +inline const char* linkName(LinkState state) +{ + switch (state) + { + case LinkState::Disabled: return "disabled"; + case LinkState::Disconnected: return "disconnected"; + case LinkState::Connecting: return "connecting"; + case LinkState::Handshaking: return "handshaking"; + case LinkState::Ready: return "ready"; + } + return "?"; +} + +inline long long nowMs() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +inline void logLine(const char* format, ...) +{ + va_list arguments; + va_start(arguments, format); + fprintf(stderr, "[ant-walk] "); + vfprintf(stderr, format, arguments); + fprintf(stderr, "\n"); + va_end(arguments); + fflush(stderr); +} + +inline bool isEnabled() +{ + return gState.threadCount > 0 && !gState.socketPath.empty(); +} + +// ── failed-record bitmap ──────────────────────────────────────────────────────────────────────── +// Scheduling only: the on-demand rebuild path ignores this and still walks. + +inline void ensureFailedBits() +{ + const size_t needed = (size_t)ANT_MAX_NODES_PER_EPOCH / 8; + if (gState.failedBits.size() != needed) + { + gState.failedBits.assign(needed, 0); + } +} + +inline bool isFailed(unsigned int index) +{ + ensureFailedBits(); + return (gState.failedBits[index >> 3] >> (index & 7)) & 1; +} + +inline void markFailed(unsigned int index) +{ + ensureFailedBits(); + if (!isFailed(index)) + { + gState.failedCount.fetch_add(1, std::memory_order_relaxed); + } + gState.failedBits[index >> 3] |= (unsigned char)(1u << (index & 7)); +} + +inline void clearFailed(unsigned int index) +{ + ensureFailedBits(); + if (isFailed(index)) + { + gState.failedCount.fetch_sub(1, std::memory_order_relaxed); + } + gState.failedBits[index >> 3] &= (unsigned char)~(1u << (index & 7)); +} + +// ── framing ───────────────────────────────────────────────────────────────────────────────────── + +inline bool writeFully(int fd, const void* buffer, size_t size) +{ + const unsigned char* in = (const unsigned char*)buffer; + size_t done = 0; + while (done < size) + { + const ssize_t put = write(fd, in + done, size - done); + if (put > 0) + { + done += (size_t)put; + continue; + } + if (put < 0 && errno == EINTR) + { + continue; + } + return false; + } + return true; +} + +inline bool readFully(int fd, void* buffer, size_t size) +{ + unsigned char* out = (unsigned char*)buffer; + size_t done = 0; + while (done < size) + { + const ssize_t got = read(fd, out + done, size - done); + if (got > 0) + { + done += (size_t)got; + continue; + } + if (got < 0 && errno == EINTR) + { + continue; + } + return false; + } + return true; +} + +inline bool sendFrame(int fd, unsigned int type, const void* payload, unsigned int payloadSize) +{ + AntWalkProto::FrameHeader header; + header.magic = AntWalkProto::MAGIC; + header.type = type; + header.payloadSize = payloadSize; + header.reserved = 0; + if (!writeFully(fd, &header, sizeof(header))) + { + return false; + } + return payloadSize == 0 || writeFully(fd, payload, payloadSize); +} + +// ── link ──────────────────────────────────────────────────────────────────────────────────────── + +// Releases every in-flight claim: the on-demand path would otherwise wait on a dead connection. +inline void dropLink(const char* reason) +{ + const int fd = gState.fd.exchange(-1); + if (fd >= 0) + { + close(fd); + } + unsigned int released = 0; + for (const InFlight& job : gState.inFlight) + { + gAntColony.releaseAnnClaim(job.recordIndex); + released++; + } + gState.inFlight.clear(); + gState.inFlightCount.store(0, std::memory_order_release); + if (gState.link.load(std::memory_order_acquire) != (int)LinkState::Disconnected) + { + logLine("sidecar lost (%s), %u claims released", reason, released); + } + gState.link.store((int)LinkState::Disconnected, std::memory_order_release); +} + +inline bool sendHello(int fd) +{ + AntWalkProto::HelloPayload hello; + memset(&hello, 0, sizeof(hello)); + hello.version = AntWalkProto::VERSION; + hello.epochId = gState.seedGeneration.load(std::memory_order_acquire); + hello.annBytes = AntWalkProto::ANN_BYTES; + hello.configHash = AntWalkProto::CONFIG_HASH; + gState.helloGeneration = hello.epochId; + copyMem(hello.miningSeed, score->currentRandomSeed.m256i_u8, 32); + copyMem(hello.topologyHash, BPP9000_TOPOLOGY_HASH, 32); + copyMem(hello.dataHash, BPP9000_DATA_HASH, 32); + return sendFrame(fd, AntWalkProto::MsgHello, &hello, (unsigned int)sizeof(hello)); +} + +inline bool tryConnect() +{ + gState.link.store((int)LinkState::Connecting, std::memory_order_release); + const int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + { + return false; + } + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + snprintf(address.sun_path, sizeof(address.sun_path), "%s", gState.socketPath.c_str()); + if (connect(fd, (struct sockaddr*)&address, sizeof(address)) != 0) + { + close(fd); + gState.link.store((int)LinkState::Disconnected, std::memory_order_release); + return false; + } + + gState.fd.store(fd, std::memory_order_release); + gState.link.store((int)LinkState::Handshaking, std::memory_order_release); + gState.handshakeStartedAtMs = nowMs(); + if (!sendHello(fd)) + { + dropLink("hello write failed"); + return false; + } + return true; +} + +// ── selection ─────────────────────────────────────────────────────────────────────────────────── + +// Commit order is topological, so this walks each lineage bottom-up without repeating a level. +inline bool selectNextRecord(unsigned int& outIndex) +{ + const unsigned int recordCount = gAntColony.solutionCount(); + if (recordCount == 0) + { + return false; + } + + for (unsigned int scanned = 0; scanned < recordCount; scanned++) + { + const unsigned int index = (gState.cursor + scanned) % recordCount; + if (index == gState.handedBack.load(std::memory_order_acquire)) + { + // Held until the tick has actually taken it, so the walker cannot re-take what it gave up. + if (gAntColony.isAnnClaimHeld(index) || gAntColony.isAnnMaterialised(index)) + { + gState.handedBack.store(PREEMPT_NONE, std::memory_order_release); + } + continue; + } + if (isFailed(index) || !AntColonyMaintenance::isRebuildableNow(gAntColony, index)) + { + continue; + } + gState.cursor = (index + 1) % recordCount; + outIndex = index; + return true; + } + return false; +} + +// A full record scan (2^23 on mainnet), so it runs on the heartbeat rather than per dispatch. +inline void refreshBacklog() +{ + const unsigned int recordCount = gAntColony.solutionCount(); + unsigned int backlogCount = 0; + for (unsigned int index = 0; index < recordCount; index++) + { + if (!gAntColony.isAnnMaterialised(index)) + { + backlogCount++; + } + } + gState.backlog.store(backlogCount, std::memory_order_release); +} + +inline bool dispatchOne() +{ + unsigned int index; + if (!selectNextRecord(index)) + { + return false; + } + if (gAntColony.tryClaimAnn(index) != AntColonyBpp9000T::AnnClaimOwned) + { + return false; + } + const AntSolutionRecord* record = gAntColony.recordAt(index); + if (record == nullptr) + { + gAntColony.releaseAnnClaim(index); + return false; + } + + AntWalkProto::JobPayload job; + memset(&job, 0, sizeof(job)); + job.jobId = gState.nextJobId++; + job.epochId = gState.seedGeneration.load(std::memory_order_acquire); + job.isRoot = record->parentRef.isRoot() ? 1u : 0u; + copyMem(job.pubkey, record->pubkey.m256i_u8, 32); + copyMem(job.nonce, record->nonce.m256i_u8, 32); + + m256i anchorDigest; + if (!getAntAnchorDigestForRebuild(record->anchorTick, anchorDigest)) + { + gAntColony.releaseAnnClaim(index); + markFailed(index); + return false; + } + copyMem(job.anchorDigest, anchorDigest.m256i_u8, 32); + + // Every other scoring path memoises its walk, so a score already held here costs no job at all. + const AntColonyBpp9000T::ReplayKey replayKey = makeAntReplayKey(record->pubkey, record->nonce, record->parentRef, anchorDigest); + AntColonyBpp9000T::Ann memoAnn; + unsigned int memoScore; + if (gAntColony.tryGetReplayScore(replayKey, memoScore, memoAnn)) + { + if (memoScore != record->score) + { + // The node's own cache disagreeing is the record's fault, not the walker's, so no streak. + gAntColony.releaseAnnClaim(index); + markFailed(index); + logLine("record %u memo %u != accepted %u, marked failed", index, memoScore, record->score); + return false; + } + unsigned int memoHash; + KangarooTwelve(&memoAnn, sizeof(memoAnn), &memoHash, sizeof(memoHash)); + gAntColony.publishAnn(index, memoAnn, memoHash); + gState.memoHits.fetch_add(1, std::memory_order_relaxed); + gState.materialised.fetch_add(1, std::memory_order_relaxed); + if (gState.debug) + { + logLine("record %u from the replay cache, no job", index); + } + return true; + } + + if (!job.isRoot) + { + const long long parentIndex = gAntColony.findIndexBySolutionRef(record->parentRef); + const AntSolutionRecord* parentRecord = (parentIndex == ANT_INVALID_INDEX) ? nullptr : gAntColony.recordAt(parentIndex); + AntColonyBpp9000T::Ann parentAnn; + if (parentRecord == nullptr || !gAntColony.annOfNonRoot(*parentRecord, parentAnn)) + { + gAntColony.releaseAnnClaim(index); + return false; + } + copyMem(job.parentAnn, &parentAnn, sizeof(parentAnn)); + } + + const int fd = gState.fd.load(std::memory_order_acquire); + if (fd < 0 || !sendFrame(fd, AntWalkProto::MsgJob, &job, (unsigned int)sizeof(job))) + { + gAntColony.releaseAnnClaim(index); + dropLink("job write failed"); + return false; + } + + gState.inFlight.push_back(InFlight{ job.jobId, index, nowMs(), anchorDigest }); + gState.inFlightCount.store((unsigned int)gState.inFlight.size(), std::memory_order_release); + gState.jobsSent.fetch_add(1, std::memory_order_relaxed); + if (gState.debug) + { + logLine("job %llu record %u depth %u sent", (unsigned long long)job.jobId, index, (unsigned)record->depth); + } + return true; +} + +// ── results ───────────────────────────────────────────────────────────────────────────────────── + +inline bool takeInFlight(unsigned long long jobId, InFlight& out) +{ + for (size_t i = 0; i < gState.inFlight.size(); i++) + { + if (gState.inFlight[i].jobId == jobId) + { + out = gState.inFlight[i]; + gState.inFlight.erase(gState.inFlight.begin() + (long)i); + gState.inFlightCount.store((unsigned int)gState.inFlight.size(), std::memory_order_release); + return true; + } + } + return false; +} + +inline void noteDisagreement(unsigned int index) +{ + markFailed(index); + gState.rolledBackCandidates.push_back(index); + gState.disagreements.fetch_add(1, std::memory_order_relaxed); + gState.disagreementStreak++; + if (gState.disagreementStreak < DISAGREEMENT_STREAK_LIMIT) + { + return; + } + for (unsigned int candidate : gState.rolledBackCandidates) + { + clearFailed(candidate); + } + logLine("%u consecutive disagreements - walker suspect, %u marks rolled back, disconnecting", + gState.disagreementStreak, (unsigned int)gState.rolledBackCandidates.size()); + gState.rolledBackCandidates.clear(); + gState.disagreementStreak = 0; + gState.suspectWalkerPid.store(gState.walkerPid.load(std::memory_order_acquire), std::memory_order_release); + dropLink("walker disagrees on every job"); +} + +inline void noteSuccess() +{ + gState.disagreementStreak = 0; + gState.deadlineStreak = 0; + gState.rolledBackCandidates.clear(); +} + +inline void applyResult(const AntWalkProto::ResultPayload& result) +{ + InFlight job; + if (!takeInFlight(result.jobId, job)) + { + return; + } + + const long long walkMs = nowMs() - job.sentAtMs; + const unsigned long long previousEma = gState.walkMsEma.load(std::memory_order_acquire); + gState.walkMsEma.store(previousEma ? (previousEma * 3 + (unsigned long long)walkMs) / 4 : (unsigned long long)walkMs, std::memory_order_release); + gState.lastResultAtMs.store((unsigned long long)nowMs(), std::memory_order_release); + + if (result.epochId != gState.seedGeneration.load(std::memory_order_acquire) || result.status == AntWalkProto::ResultStaleEpoch) + { + gAntColony.releaseAnnClaim(job.recordIndex); + gState.staleDropped.fetch_add(1, std::memory_order_relaxed); + logLine("job %llu dropped, epoch %u is not %u", (unsigned long long)result.jobId, + result.epochId, gState.seedGeneration.load(std::memory_order_acquire)); + return; + } + + const AntSolutionRecord* record = gAntColony.recordAt(job.recordIndex); + if (record == nullptr) + { + gAntColony.releaseAnnClaim(job.recordIndex); + return; + } + + if (result.status != AntWalkProto::ResultOk || result.score != record->score) + { + gAntColony.releaseAnnClaim(job.recordIndex); + logLine("record %u walked %u != accepted %u, marked failed", job.recordIndex, result.score, record->score); + noteDisagreement(job.recordIndex); + return; + } + + AntColonyBpp9000T::Ann childAnn; + copyMem(&childAnn, result.childAnn, sizeof(childAnn)); + unsigned int annHash; + KangarooTwelve(&childAnn, sizeof(childAnn), &annHash, sizeof(annHash)); + + gAntColony.publishAnn(job.recordIndex, childAnn, annHash); + // The cache every scoring path consults, so a strict replay of this solution is a lookup. + const AntColonyBpp9000T::ReplayKey replayKey = makeAntReplayKey(record->pubkey, record->nonce, record->parentRef, job.anchorDigest); + gAntColony.putReplayScore(replayKey, result.score, childAnn); + gState.materialised.fetch_add(1, std::memory_order_relaxed); + noteSuccess(); + if (gState.debug) + { + logLine("job %llu record %u score %u in %lld ms", (unsigned long long)result.jobId, job.recordIndex, result.score, walkMs); + } +} + +inline void checkDeadlines() +{ + const unsigned long long ema = gState.walkMsEma.load(std::memory_order_acquire); + const long long deadlineMs = (long long)(ema * 3) > MIN_JOB_DEADLINE_MS + ? (long long)(ema * 3) : MIN_JOB_DEADLINE_MS; + const long long now = nowMs(); + + for (size_t i = 0; i < gState.inFlight.size();) + { + if (now - gState.inFlight[i].sentAtMs < deadlineMs) + { + i++; + continue; + } + const unsigned int index = gState.inFlight[i].recordIndex; + const unsigned long long jobId = gState.inFlight[i].jobId; + gState.inFlight.erase(gState.inFlight.begin() + (long)i); + gAntColony.releaseAnnClaim(index); + gState.deadlineExpiries.fetch_add(1, std::memory_order_relaxed); + // A missing result says nothing about the record, so the bitmap is left alone. + logLine("job %llu record %u no result in %lld ms, claim released, not marked", jobId, index, deadlineMs); + gState.deadlineStreak++; + } + gState.inFlightCount.store((unsigned int)gState.inFlight.size(), std::memory_order_release); + + if (gState.deadlineStreak >= DEADLINE_STREAK_LIMIT) + { + gState.deadlineStreak = 0; + dropLink("walker alive but not answering"); + } +} + +// ── dispatcher ────────────────────────────────────────────────────────────────────────────────── + +// Answers the on-demand path waiting on a record this link claimed. Only a job past the stale age is +// handed back: a healthy walk is worth waiting out, since taking it back discards all of its progress. +inline void servePreempt() +{ + const unsigned int index = gState.preemptRequest.load(std::memory_order_acquire); + if (index == PREEMPT_NONE) + { + return; + } + + const unsigned long long ema = gState.walkMsEma.load(std::memory_order_acquire); + const long long staleMs = (long long)(ema * 2) > PREEMPT_MIN_AGE_MS ? (long long)(ema * 2) : PREEMPT_MIN_AGE_MS; + for (size_t i = 0; i < gState.inFlight.size(); i++) + { + if (gState.inFlight[i].recordIndex != index || nowMs() - gState.inFlight[i].sentAtMs < staleMs) + { + continue; + } + const unsigned long long jobId = gState.inFlight[i].jobId; + const long long ageMs = nowMs() - gState.inFlight[i].sentAtMs; + gState.inFlight.erase(gState.inFlight.begin() + (long)i); + gState.inFlightCount.store((unsigned int)gState.inFlight.size(), std::memory_order_release); + gState.handedBack.store(index, std::memory_order_release); + gAntColony.releaseAnnClaim(index); + gState.preempted.fetch_add(1, std::memory_order_relaxed); + logLine("job %llu record %u handed back to the tick after %lld ms, its result will be dropped", jobId, index, ageMs); + break; + } + gState.preemptRequest.store(PREEMPT_NONE, std::memory_order_release); +} + +// Reseeding invalidates every index in flight, so claims and scan state go before it proceeds. +inline void serveQuiesce() +{ + for (const InFlight& job : gState.inFlight) + { + gAntColony.releaseAnnClaim(job.recordIndex); + } + gState.inFlight.clear(); + gState.inFlightCount.store(0, std::memory_order_release); + gState.preemptRequest.store(PREEMPT_NONE, std::memory_order_release); + gState.handedBack.store(PREEMPT_NONE, std::memory_order_release); + gState.cursor = 0; + gState.disagreementStreak = 0; + gState.deadlineStreak = 0; + gState.rolledBackCandidates.clear(); + gState.failedBits.assign((size_t)ANT_MAX_NODES_PER_EPOCH / 8, 0); + gState.failedCount.store(0, std::memory_order_release); + gState.quiesceAcknowledged.store(true, std::memory_order_release); + + while (gState.quiesceRequested.load(std::memory_order_acquire) && !gState.stopping.load(std::memory_order_acquire)) + { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + const int fd = gState.fd.load(std::memory_order_acquire); + if (fd >= 0 && !sendHello(fd)) + { + dropLink("hello write failed after epoch reset"); + } +} + +inline bool readOneFrame(int fd, unsigned char* payload) +{ + AntWalkProto::FrameHeader header; + if (!readFully(fd, &header, sizeof(header))) + { + dropLink("connection closed"); + return false; + } + if (header.magic != AntWalkProto::MAGIC || header.payloadSize > AntWalkProto::MAX_PAYLOAD_BYTES) + { + dropLink("bad frame"); + return false; + } + if (header.payloadSize && !readFully(fd, payload, header.payloadSize)) + { + dropLink("short frame"); + return false; + } + + if (header.type == AntWalkProto::MsgReady) + { + if (header.payloadSize != sizeof(AntWalkProto::ReadyPayload)) + { + dropLink("bad ready frame"); + return false; + } + AntWalkProto::ReadyPayload ready; + memcpy(&ready, payload, sizeof(ready)); + if ((int)ready.walkerPid == gState.suspectWalkerPid.load(std::memory_order_acquire)) + { + logLine("walker pid %u already disagreed on every job, not using it", ready.walkerPid); + dropLink("suspect walker"); + return false; + } + if (ready.status != AntWalkProto::ReadyOk) + { + logLine("handshake refused by the walker (status %u) - not retrying until it changes", ready.status); + dropLink("handshake refused"); + return false; + } + // Also answers the hello resent on a seed rotation, which is a re-seed, not a new link. + const bool wasReady = (LinkState)gState.link.load(std::memory_order_acquire) == LinkState::Ready; + gState.threadCount = ready.threadCount; + gState.walkerPid.store((int)ready.walkerPid, std::memory_order_release); + gState.link.store((int)LinkState::Ready, std::memory_order_release); + if (wasReady) + { + logLine("sidecar re-seeded for epoch %u", ready.epochId); + } + else + { + gState.reconnects.fetch_add(1, std::memory_order_relaxed); + logLine("sidecar connected, pid %u, %u threads, epoch %u", ready.walkerPid, ready.threadCount, ready.epochId); + } + } + else if (header.type == AntWalkProto::MsgResult) + { + if (header.payloadSize != sizeof(AntWalkProto::ResultPayload)) + { + dropLink("bad result frame"); + return false; + } + AntWalkProto::ResultPayload result; + memcpy(&result, payload, sizeof(result)); + applyResult(result); + } + return true; +} + +inline void heartbeat() +{ + logLine("backlog %llu, done %llu (memo %llu), failed %llu, inflight %u/%u, walk avg %llu ms, link %s", + (unsigned long long)gState.backlog.load(std::memory_order_acquire), (unsigned long long)gState.materialised.load(std::memory_order_acquire), + (unsigned long long)gState.memoHits.load(std::memory_order_acquire), (unsigned long long)gState.failedCount.load(std::memory_order_acquire), + gState.inFlightCount.load(std::memory_order_acquire), gState.threadCount, (unsigned long long)gState.walkMsEma.load(std::memory_order_acquire), + linkName((LinkState)gState.link.load(std::memory_order_acquire))); +} + +inline void dispatcherLoop() +{ + long long backoffMs = BACKOFF_MIN_MS; + long long reconnectAtMs = 0; + long long nextPingAtMs = nowMs() + PING_INTERVAL_MS; + long long nextHeartbeatAtMs = nowMs() + HEARTBEAT_INTERVAL_MS; + std::vector payload(AntWalkProto::MAX_PAYLOAD_BYTES); + + while (!gState.stopping.load(std::memory_order_acquire)) + { + servePreempt(); + + if (nowMs() >= nextHeartbeatAtMs) + { + nextHeartbeatAtMs = nowMs() + HEARTBEAT_INTERVAL_MS; + refreshBacklog(); + heartbeat(); + } + + if (gState.quiesceRequested.load(std::memory_order_acquire)) + { + serveQuiesce(); + continue; + } + + // A walker seeded before the first mining seed exists would disagree on every record. + if (score == nullptr || isZero(score->currentRandomSeed)) + { + std::this_thread::sleep_for(std::chrono::milliseconds(POLL_SLICE_MS)); + continue; + } + + const LinkState link = (LinkState)gState.link.load(std::memory_order_acquire); + if (link != LinkState::Ready && link != LinkState::Handshaking) + { + const long long now = nowMs(); + if (now < reconnectAtMs) + { + std::this_thread::sleep_for(std::chrono::milliseconds(POLL_SLICE_MS)); + continue; + } + if (!tryConnect()) + { + backoffMs = (backoffMs * 2 < BACKOFF_MAX_MS) ? backoffMs * 2 : BACKOFF_MAX_MS; + reconnectAtMs = nowMs() + backoffMs; + continue; + } + backoffMs = BACKOFF_MIN_MS; + continue; + } + + if (link == LinkState::Handshaking && nowMs() - gState.handshakeStartedAtMs > HANDSHAKE_DEADLINE_MS) + { + dropLink("no ready frame within the handshake deadline"); + continue; + } + + const int fd = gState.fd.load(std::memory_order_acquire); + if (fd < 0) + { + continue; + } + + struct pollfd entry; + entry.fd = fd; + entry.events = POLLIN; + entry.revents = 0; + const int ready = poll(&entry, 1, POLL_SLICE_MS); + if (ready > 0 && !readOneFrame(fd, payload.data())) + { + continue; + } + if (ready < 0 && errno != EINTR) + { + dropLink("poll failed"); + continue; + } + + checkDeadlines(); + + // Deeper only lets a wedged walker hold claims the on-demand path may need. + if ((LinkState)gState.link.load(std::memory_order_acquire) == LinkState::Ready) + { + while (gState.inFlight.size() < gState.threadCount && dispatchOne()) + { + } + } + + const unsigned int generation = gState.seedGeneration.load(std::memory_order_acquire); + if (generation != gState.helloGeneration && (LinkState)gState.link.load(std::memory_order_acquire) == LinkState::Ready) + { + const int helloFd = gState.fd.load(std::memory_order_acquire); + if (helloFd < 0 || !sendHello(helloFd)) + { + dropLink("hello write failed on seed change"); + continue; + } + } + + const long long now = nowMs(); + if (now >= nextPingAtMs) + { + nextPingAtMs = now + PING_INTERVAL_MS; + const int liveFd = gState.fd.load(std::memory_order_acquire); + if (liveFd >= 0 && !sendFrame(liveFd, AntWalkProto::MsgPing, nullptr, 0)) + { + dropLink("ping write failed"); + } + } + } + + dropLink("shutting down"); +} + +// ── node-facing api ───────────────────────────────────────────────────────────────────────────── + +inline void configure(const std::string& socketPath, unsigned int threadCount, bool debug) +{ + gState.socketPath = socketPath; + gState.threadCount = threadCount; + gState.debug = debug; +} + +inline void start() +{ + if (!isEnabled() || gState.dispatcher != nullptr) + { + return; + } + gState.stopping.store(false, std::memory_order_release); + gState.link.store((int)LinkState::Disconnected, std::memory_order_release); + gState.dispatcher = new std::thread(dispatcherLoop); +} + +inline void stop() +{ + if (gState.dispatcher == nullptr) + { + return; + } + gState.stopping.store(true, std::memory_order_release); + gState.dispatcher->join(); + delete gState.dispatcher; + gState.dispatcher = nullptr; +} + +// The pool seed changed, so results still in flight were computed against the old one. +inline void onEpochBegin() +{ + gState.seedGeneration.fetch_add(1, std::memory_order_acq_rel); +} + +inline void quiesceBegin() +{ + if (gState.dispatcher == nullptr) + { + return; + } + gState.quiesceAcknowledged.store(false, std::memory_order_release); + gState.quiesceRequested.store(true, std::memory_order_release); + + const long long startedAtMs = nowMs(); + while (!gState.quiesceAcknowledged.load(std::memory_order_acquire)) + { + if (nowMs() - startedAtMs > 5'000) + { + logLine("quiesce not acknowledged in 5000 ms, continuing"); + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + logLine("quiesce for epoch reset, waited %lld ms", nowMs() - startedAtMs); +} + +inline void quiesceEnd() +{ + gState.quiesceRequested.store(false, std::memory_order_release); +} + +// Asks the dispatcher to hand back a record the on-demand path is stuck behind. The caller re-checks +// the claim afterwards: a claim held by another node processor is not the walker's to release. +inline void preemptClaim(unsigned int index) +{ + if (!isEnabled() || gState.inFlightCount.load(std::memory_order_acquire) == 0) + { + return; + } + unsigned int slot = PREEMPT_NONE; + if (!gState.preemptRequest.compare_exchange_strong(slot, index, std::memory_order_acq_rel)) + { + return; + } + + const long long deadlineMs = nowMs() + PREEMPT_ACK_WAIT_MS; + while (gState.preemptRequest.load(std::memory_order_acquire) == index && nowMs() < deadlineMs) + { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + // A dispatcher that never answered must not leave the slot taken for every later request. + slot = index; + gState.preemptRequest.compare_exchange_strong(slot, PREEMPT_NONE, std::memory_order_acq_rel); +} + +// The fork child inherits the descriptor; two readers on one socket interleave results. +inline void closeInheritedSocket() +{ + const int fd = gState.fd.exchange(-1); + if (fd >= 0) + { + close(fd); + } + gState.link.store((int)LinkState::Disconnected, std::memory_order_release); +} + +// The promote path swept the claims, so only the client's own view is rebuilt here. +inline void restartAfterPromote() +{ + closeInheritedSocket(); + // Only the handle came through fork(), so it is abandoned rather than joined or destroyed. + gState.dispatcher = nullptr; + gState.inFlight.clear(); + gState.inFlightCount.store(0, std::memory_order_release); + gState.preemptRequest.store(PREEMPT_NONE, std::memory_order_release); + gState.handedBack.store(PREEMPT_NONE, std::memory_order_release); + gState.quiesceRequested.store(false, std::memory_order_release); + gState.quiesceAcknowledged.store(true, std::memory_order_release); + gState.cursor = 0; + gState.disagreementStreak = 0; + gState.deadlineStreak = 0; + gState.rolledBackCandidates.clear(); + start(); + if (isEnabled()) + { + logLine("dispatcher restarted after promote"); + } +} + +inline std::string statsJson() +{ + char buffer[896]; + snprintf(buffer, sizeof(buffer), + "{\"enabled\":%s,\"state\":\"%s\",\"socket\":\"%s\",\"threads\":%u," + "\"inflight\":%u,\"backlog\":%llu,\"materialised\":%llu,\"failed\":%llu," + "\"jobsSent\":%llu,\"disagreements\":%llu,\"deadlineExpiries\":%llu," + "\"staleDropped\":%llu,\"reconnects\":%llu,\"walkAvgMs\":%llu," + "\"memoHits\":%llu,\"preempted\":%llu,\"epochId\":%u}", + isEnabled() ? "true" : "false", linkName((LinkState)gState.link.load(std::memory_order_acquire)), gState.socketPath.c_str(), gState.threadCount, + gState.inFlightCount.load(std::memory_order_acquire), (unsigned long long)gState.backlog.load(std::memory_order_acquire), + (unsigned long long)gState.materialised.load(std::memory_order_acquire), (unsigned long long)gState.failedCount.load(std::memory_order_acquire), + (unsigned long long)gState.jobsSent.load(std::memory_order_acquire), (unsigned long long)gState.disagreements.load(std::memory_order_acquire), + (unsigned long long)gState.deadlineExpiries.load(std::memory_order_acquire), (unsigned long long)gState.staleDropped.load(std::memory_order_acquire), + (unsigned long long)gState.reconnects.load(std::memory_order_acquire), (unsigned long long)gState.walkMsEma.load(std::memory_order_acquire), + (unsigned long long)gState.memoHits.load(std::memory_order_acquire), (unsigned long long)gState.preempted.load(std::memory_order_acquire), + gState.seedGeneration.load(std::memory_order_acquire)); + return std::string(buffer); +} +} + +#else + +#include + +namespace AntWalker +{ +inline void configure(const std::string&, unsigned int, bool) {} +inline void start() {} +inline void stop() {} +inline void onEpochBegin() {} +inline void quiesceBegin() {} +inline void quiesceEnd() {} +inline void preemptClaim(unsigned int) {} +inline void closeInheritedSocket() {} +inline void restartAfterPromote() {} +inline std::string statsJson() { return std::string("{\"enabled\":false}"); } +} + +#endif // ANT_WALKER && !_WIN32 diff --git a/src/extensions/ant_walker_proto.h b/src/extensions/ant_walker_proto.h new file mode 100644 index 00000000..7f8f5e80 --- /dev/null +++ b/src/extensions/ant_walker_proto.h @@ -0,0 +1,128 @@ +#pragma once + +// Wire format shared by the walker client and the walker process. Fixed-size payloads only, so a +// frame is one read of a known length. + +#include "score.h" +#include "public_settings.h" + +namespace AntWalkProto +{ +static constexpr unsigned int MAGIC = 0x57544E41u; // "ANTW" +static constexpr unsigned int VERSION = 1; + +static constexpr unsigned int ANN_BYTES = (unsigned int)sizeof(score_engine::ScoreBpp9000T::ANN); + +// Both sides compile the same scorer, so only a build disagreeing on these params scores +// differently. Compared at the handshake, before a job runs. +constexpr unsigned int mixConfig(unsigned int accumulated, unsigned long long value) +{ + for (unsigned int byteIndex = 0; byteIndex < 8; byteIndex++) + { + accumulated = (accumulated ^ (unsigned int)((value >> (byteIndex * 8)) & 0xFF)) * 16777619u; + } + return accumulated; +} + +constexpr unsigned int CONFIG_HASH = + mixConfig(mixConfig(mixConfig(mixConfig(mixConfig(mixConfig(mixConfig(mixConfig(mixConfig(mixConfig( + 2166136261u, + BPP9000_NUMBER_OF_INPUT_NEURONS), + BPP9000_NUMBER_OF_OUTPUT_NEURONS), + BPP9000_SEQUENCE_LENGTH), + BPP9000_WINDOW_WIDTH), + BPP9000_MAX_NUMBER_OF_TICKS), + BPP9000_NUMBER_OF_NEIGHBORS), + BPP9000_POPULATION_THRESHOLD), + BPP9000_NUMBER_OF_MUTATIONS), + BPP9000_SOLUTION_THRESHOLD_DEFAULT), + (unsigned long long)ANN_BYTES); + +enum MessageType : unsigned int +{ + MsgHello = 1, // node -> walker + MsgReady = 2, // walker -> node + MsgJob = 3, // node -> walker + MsgResult = 4, // walker -> node + MsgPing = 5, // node -> walker + MsgPong = 6, // walker -> node +}; + +enum ResultStatus : unsigned int +{ + ResultOk = 0, + ResultUnscorable = 1, // the engine returned INVALID_SCORE_VALUE, childAnn is not written + ResultStaleEpoch = 2, // the job named an epoch the walker is no longer seeded for +}; + +enum ReadyStatus : unsigned int +{ + ReadyOk = 0, + ReadyVersionMismatch = 1, + ReadyTaskMismatch = 2, + ReadySeedFailed = 3, + ReadyConfigMismatch = 4, +}; + +struct FrameHeader +{ + unsigned int magic; + unsigned int type; + unsigned int payloadSize; + unsigned int reserved; +}; + +struct HelloPayload +{ + unsigned int version; + unsigned int epochId; + unsigned int annBytes; + unsigned int configHash; + unsigned char miningSeed[32]; + // The pinned task identity both binaries were compiled against; a mismatch means the two would + // score the same nonce differently, which is worth refusing before a single job runs. + unsigned char topologyHash[32]; + unsigned char dataHash[32]; +}; + +struct ReadyPayload +{ + unsigned int version; + unsigned int status; + unsigned int threadCount; + unsigned int annBytes; + unsigned int epochId; + unsigned int walkerPid; // lets the node refuse a walker it has already found to be wrong +}; + +struct JobPayload +{ + unsigned long long jobId; + unsigned int epochId; + unsigned int isRoot; // parentAnn is unused when set; the walker derives the miner's root + unsigned char pubkey[32]; + unsigned char nonce[32]; + unsigned char anchorDigest[32]; + unsigned char parentAnn[ANN_BYTES]; +}; + +struct ResultPayload +{ + unsigned long long jobId; + unsigned int epochId; + unsigned int status; + unsigned int score; + unsigned int reserved; + unsigned char childAnn[ANN_BYTES]; +}; + +static_assert(sizeof(FrameHeader) == 16, "FrameHeader must stay 16 bytes on the wire"); +static_assert(sizeof(HelloPayload) == 112, "HelloPayload layout changed - bump VERSION"); +static_assert(sizeof(ReadyPayload) == 24, "ReadyPayload layout changed - bump VERSION"); +static_assert(sizeof(JobPayload) == 16 + 96 + ANN_BYTES, "JobPayload layout changed - bump VERSION"); +static_assert(sizeof(ResultPayload) == 24 + ANN_BYTES, "ResultPayload layout changed - bump VERSION"); + +// The largest frame either side ever reads, so both can size one static buffer. +static constexpr unsigned int MAX_PAYLOAD_BYTES = + (sizeof(JobPayload) > sizeof(ResultPayload)) ? (unsigned int)sizeof(JobPayload) : (unsigned int)sizeof(ResultPayload); +} diff --git a/src/extensions/ant_walker_worker.h b/src/extensions/ant_walker_worker.h new file mode 100644 index 00000000..a191ef4d --- /dev/null +++ b/src/extensions/ant_walker_worker.h @@ -0,0 +1,581 @@ +#pragma once + +// The ant score walk, in a process re-exec'd from this binary (--ant-walk-worker): on a node thread +// it would hold the score-engine lock across nearly every fork point. Stateless, holds no node state. + +#if defined(ANT_WALKER) && !defined(_WIN32) + +#include "ant_walker_proto.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#if defined(__linux__) +#include +#endif + +namespace AntWalkerWorker +{ +using Ann = score_engine::ScoreBpp9000T::ANN; + +constexpr int NO_TRAFFIC_TIMEOUT_MS = 60'000; +constexpr int POLL_SLICE_MS = 1'000; + +struct Options +{ + std::string socketPath; + unsigned int threadCount = 4; +}; + +// ── socket helpers ────────────────────────────────────────────────────────────────────────────── + +bool readFully(int fd, void* buffer, size_t size) +{ + unsigned char* out = (unsigned char*)buffer; + size_t done = 0; + while (done < size) + { + const ssize_t got = read(fd, out + done, size - done); + if (got > 0) + { + done += (size_t)got; + continue; + } + if (got < 0 && errno == EINTR) + { + continue; + } + return false; + } + return true; +} + +bool writeFully(int fd, const void* buffer, size_t size) +{ + const unsigned char* in = (const unsigned char*)buffer; + size_t done = 0; + while (done < size) + { + const ssize_t put = write(fd, in + done, size - done); + if (put > 0) + { + done += (size_t)put; + continue; + } + if (put < 0 && errno == EINTR) + { + continue; + } + return false; + } + return true; +} + +// Bounded, so a peer that vanished without an EOF cannot park this process forever. +bool waitReadable(int fd, int timeoutMs, const std::atomic& stop) +{ + int waitedMs = 0; + while (waitedMs < timeoutMs) + { + if (stop.load(std::memory_order_acquire)) + { + return false; + } + struct pollfd entry; + entry.fd = fd; + entry.events = POLLIN; + entry.revents = 0; + const int slice = (timeoutMs - waitedMs < POLL_SLICE_MS) ? (timeoutMs - waitedMs) : POLL_SLICE_MS; + const int ready = poll(&entry, 1, slice); + if (ready > 0) + { + return true; + } + if (ready < 0 && errno != EINTR) + { + return false; + } + waitedMs += slice; + } + return false; +} + +// ── engine ────────────────────────────────────────────────────────────────────────────────────── + +unsigned char* gPool = nullptr; +unsigned char gPoolSeed[32] = {}; +bool gPoolSeeded = false; + +bool ensurePool(const unsigned char* miningSeed) +{ + if (gPool == nullptr) + { + gPool = (unsigned char*)malloc((size_t)score_engine::POOL_VEC_PADDING_SIZE); + if (gPool == nullptr) + { + return false; + } + } + if (gPoolSeeded && memcmp(gPoolSeed, miningSeed, 32) == 0) + { + return true; + } + unsigned char state[score_engine::STATE_SIZE]; + score_engine::generateRandom2Pool(miningSeed, state, gPool); + memcpy(gPoolSeed, miningSeed, 32); + gPoolSeeded = true; + return true; +} + +bool loadEmbeddedTask(score_engine::ScoreBpp9000T& engine) +{ + const unsigned int inputTrits = (unsigned int)BPP9000_NUMBER_OF_INPUT_NEURONS; + const unsigned int outputTrits = (unsigned int)BPP9000_NUMBER_OF_OUTPUT_NEURONS; + const unsigned int population = (unsigned int)BPP9000_POPULATION_THRESHOLD; + const unsigned int neighbors = (unsigned int)BPP9000_NUMBER_OF_NEIGHBORS; + + const unsigned long long topologyBytes = score_task_file::topologyBytes(inputTrits, outputTrits, population, neighbors); + const unsigned char* topologyBlock = BPP9000_TASK_BYTES + sizeof(score_task_file::TaskFileHeader); + const unsigned char* dataBlock = topologyBlock + topologyBytes; + + engine.initMemory(); + return engine.loadTaskFromMemory(topologyBlock, dataBlock); +} + +// ── worker pool ───────────────────────────────────────────────────────────────────────────────── + +struct WorkerPool +{ + std::mutex queueMutex; // SMARTMUTEX-EXEMPT: separate process, holds no node state and never forks + std::condition_variable queueSignal; + std::deque queue; + + std::mutex writeMutex; // SMARTMUTEX-EXEMPT: separate process, holds no node state and never forks + std::atomic stopping{ false }; + std::atomic connected{ false }; + std::atomic epochId{ 0 }; + std::atomic connectionFd{ -1 }; + std::atomic busy{ 0 }; + + std::vector threads; +}; + +WorkerPool gPoolOfWorkers; + +void runWorker(unsigned int workerIndex) +{ + score_engine::ScoreBpp9000T* engine = (score_engine::ScoreBpp9000T*)aligned_alloc(64, (sizeof(score_engine::ScoreBpp9000T) + 63) / 64 * 64); + if (engine == nullptr || !loadEmbeddedTask(*engine)) + { + fprintf(stderr, "[ant-walker] worker %u could not load the embedded task\n", workerIndex); + fflush(stderr); + return; + } + Ann rootAnn; + Ann childAnn; + + for (;;) + { + AntWalkProto::JobPayload job; + { + std::unique_lock lock(gPoolOfWorkers.queueMutex); + gPoolOfWorkers.queueSignal.wait(lock, [] { + return gPoolOfWorkers.stopping.load(std::memory_order_acquire) + || !gPoolOfWorkers.queue.empty(); + }); + if (gPoolOfWorkers.stopping.load(std::memory_order_acquire) && gPoolOfWorkers.queue.empty()) + { + break; + } + job = gPoolOfWorkers.queue.front(); + gPoolOfWorkers.queue.pop_front(); + gPoolOfWorkers.busy.fetch_add(1, std::memory_order_acq_rel); + } + + struct BusyGuard + { + ~BusyGuard() { gPoolOfWorkers.busy.fetch_sub(1, std::memory_order_acq_rel); } + } busyGuard; + + AntWalkProto::ResultPayload result; + memset(&result, 0, sizeof(result)); + result.jobId = job.jobId; + result.epochId = job.epochId; + + if (job.epochId != gPoolOfWorkers.epochId.load(std::memory_order_acquire)) + { + result.status = AntWalkProto::ResultStaleEpoch; + } + else + { + const Ann* parentAnn; + if (job.isRoot) + { + engine->deriveRootANN(job.pubkey, gPool, rootAnn); + parentAnn = &rootAnn; + } + else + { + parentAnn = (const Ann*)job.parentAnn; + } + + const unsigned int score = engine->computeScoreFromParent(*parentAnn, job.pubkey, job.nonce, job.anchorDigest, gPool); + if (score == score_engine::INVALID_SCORE_VALUE) + { + result.status = AntWalkProto::ResultUnscorable; + } + else + { + engine->getBestANN(childAnn); + result.status = AntWalkProto::ResultOk; + result.score = score; + memcpy(result.childAnn, &childAnn, sizeof(childAnn)); + } + } + + // A walk cannot be aborted, so one that outlived its connection finishes and is dropped. + if (!gPoolOfWorkers.connected.load(std::memory_order_acquire)) + { + continue; + } + std::lock_guard writeGuard(gPoolOfWorkers.writeMutex); + const int fd = gPoolOfWorkers.connectionFd.load(std::memory_order_acquire); + if (fd < 0) + { + continue; + } + AntWalkProto::FrameHeader header; + header.magic = AntWalkProto::MAGIC; + header.type = AntWalkProto::MsgResult; + header.payloadSize = (unsigned int)sizeof(result); + header.reserved = 0; + if (!writeFully(fd, &header, sizeof(header)) || !writeFully(fd, &result, sizeof(result))) + { + gPoolOfWorkers.connected.store(false, std::memory_order_release); + } + } + + free(engine); +} + +// ── connection ────────────────────────────────────────────────────────────────────────────────── + +bool sendReady(int fd, unsigned int status, unsigned int threadCount, unsigned int epochId) +{ + AntWalkProto::ReadyPayload ready; + memset(&ready, 0, sizeof(ready)); + ready.version = AntWalkProto::VERSION; + ready.status = status; + ready.threadCount = threadCount; + ready.annBytes = AntWalkProto::ANN_BYTES; + ready.epochId = epochId; + ready.walkerPid = (unsigned int)getpid(); + + AntWalkProto::FrameHeader header; + header.magic = AntWalkProto::MAGIC; + header.type = AntWalkProto::MsgReady; + header.payloadSize = (unsigned int)sizeof(ready); + header.reserved = 0; + + std::lock_guard writeGuard(gPoolOfWorkers.writeMutex); + return writeFully(fd, &header, sizeof(header)) && writeFully(fd, &ready, sizeof(ready)); +} + +unsigned int validateHello(const AntWalkProto::HelloPayload& hello) +{ + if (hello.version != AntWalkProto::VERSION || hello.annBytes != AntWalkProto::ANN_BYTES) + { + return AntWalkProto::ReadyVersionMismatch; + } + if (memcmp(hello.topologyHash, BPP9000_TOPOLOGY_HASH, 32) != 0 || memcmp(hello.dataHash, BPP9000_DATA_HASH, 32) != 0) + { + return AntWalkProto::ReadyTaskMismatch; + } + if (hello.configHash != AntWalkProto::CONFIG_HASH) + { + fprintf(stderr, "[ant-walker] scorer config %08x does not match the node's %08x\n", AntWalkProto::CONFIG_HASH, hello.configHash); + fflush(stderr); + return AntWalkProto::ReadyConfigMismatch; + } + return AntWalkProto::ReadyOk; +} + +bool hasWorkOutstanding() +{ + if (gPoolOfWorkers.busy.load(std::memory_order_acquire) > 0) + { + return true; + } + std::lock_guard queueGuard(gPoolOfWorkers.queueMutex); + return !gPoolOfWorkers.queue.empty(); +} + +void serveConnection(int fd, unsigned int threadCount) +{ + gPoolOfWorkers.connectionFd.store(fd, std::memory_order_release); + gPoolOfWorkers.connected.store(true, std::memory_order_release); + + unsigned char payload[AntWalkProto::MAX_PAYLOAD_BYTES]; + bool handshaken = false; + + while (gPoolOfWorkers.connected.load(std::memory_order_acquire)) + { + if (!waitReadable(fd, NO_TRAFFIC_TIMEOUT_MS, gPoolOfWorkers.stopping)) + { + // A walk outlasts this timeout, so silence only means a dead peer when nothing is queued. + if (hasWorkOutstanding()) + { + continue; + } + fprintf(stderr, "[ant-walker] no traffic for %d ms and nothing in flight, dropping the connection\n", NO_TRAFFIC_TIMEOUT_MS); + fflush(stderr); + break; + } + + AntWalkProto::FrameHeader header; + if (!readFully(fd, &header, sizeof(header))) + { + break; + } + if (header.magic != AntWalkProto::MAGIC || header.payloadSize > sizeof(payload)) + { + fprintf(stderr, "[ant-walker] bad frame (magic %08x size %u), dropping the connection\n", header.magic, header.payloadSize); + fflush(stderr); + break; + } + if (header.payloadSize && !readFully(fd, payload, header.payloadSize)) + { + break; + } + + if (header.type == AntWalkProto::MsgHello) + { + if (header.payloadSize != sizeof(AntWalkProto::HelloPayload)) + { + break; + } + AntWalkProto::HelloPayload hello; + memcpy(&hello, payload, sizeof(hello)); + + unsigned int status = validateHello(hello); + if (status == AntWalkProto::ReadyOk && !ensurePool(hello.miningSeed)) + { + status = AntWalkProto::ReadySeedFailed; + } + if (status == AntWalkProto::ReadyOk) + { + gPoolOfWorkers.epochId.store(hello.epochId, std::memory_order_release); + handshaken = true; + } + if (!sendReady(fd, status, threadCount, hello.epochId) || status != AntWalkProto::ReadyOk) + { + fprintf(stderr, "[ant-walker] handshake refused (status %u)\n", status); + fflush(stderr); + break; + } + fprintf(stderr, "[ant-walker] ready, %u threads, epoch %u\n", threadCount, hello.epochId); + fflush(stderr); + } + else if (header.type == AntWalkProto::MsgJob) + { + if (!handshaken || header.payloadSize != sizeof(AntWalkProto::JobPayload)) + { + break; + } + std::lock_guard queueGuard(gPoolOfWorkers.queueMutex); + gPoolOfWorkers.queue.emplace_back(); + memcpy(&gPoolOfWorkers.queue.back(), payload, sizeof(AntWalkProto::JobPayload)); + gPoolOfWorkers.queueSignal.notify_one(); + } + else if (header.type == AntWalkProto::MsgPing) + { + AntWalkProto::FrameHeader pong; + pong.magic = AntWalkProto::MAGIC; + pong.type = AntWalkProto::MsgPong; + pong.payloadSize = 0; + pong.reserved = 0; + std::lock_guard writeGuard(gPoolOfWorkers.writeMutex); + if (!writeFully(fd, &pong, sizeof(pong))) + { + break; + } + } + } + + gPoolOfWorkers.connected.store(false, std::memory_order_release); + { + // Queued jobs belong to a connection that is gone; only the walks already running are paid for. + std::lock_guard queueGuard(gPoolOfWorkers.queueMutex); + gPoolOfWorkers.queue.clear(); + } + { + // Held until no worker can still be inside writeFully on this descriptor. + std::lock_guard writeGuard(gPoolOfWorkers.writeMutex); + gPoolOfWorkers.connectionFd.store(-1, std::memory_order_release); + } + close(fd); +} + +// ── listener ──────────────────────────────────────────────────────────────────────────────────── + +// Competing with a live server is how an orphaned sidecar ends up answering for a running one. +bool socketPathIsServed(const char* path) +{ + const int probeFd = socket(AF_UNIX, SOCK_STREAM, 0); + if (probeFd < 0) + { + return false; + } + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + snprintf(address.sun_path, sizeof(address.sun_path), "%s", path); + const bool served = connect(probeFd, (struct sockaddr*)&address, sizeof(address)) == 0; + close(probeFd); + return served; +} + +int openListener(const char* path) +{ + if (socketPathIsServed(path)) + { + fprintf(stderr, "[ant-walker] %s is already served, refusing to compete\n", path); + fflush(stderr); + return -1; + } + unlink(path); + + const int listenFd = socket(AF_UNIX, SOCK_STREAM, 0); + if (listenFd < 0) + { + perror("[ant-walker] socket"); + return -1; + } + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + snprintf(address.sun_path, sizeof(address.sun_path), "%s", path); + if (bind(listenFd, (struct sockaddr*)&address, sizeof(address)) != 0 || listen(listenFd, 4) != 0) + { + perror("[ant-walker] bind/listen"); + close(listenFd); + return -1; + } + return listenFd; +} + +bool parseOptions(int argc, const char* argv[], Options& options) +{ + for (int i = 1; i + 1 < argc; i++) + { + const std::string argument = argv[i]; + if (argument == "--socket") + { + options.socketPath = argv[i + 1]; + } + else if (argument == "--threads") + { + options.threadCount = (unsigned int)strtoul(argv[i + 1], nullptr, 10); + } + } + return !options.socketPath.empty() && options.threadCount > 0; +} + +// Checked before any node setup: the node half of main() must not run in the walker. +inline bool requested(int argc, const char* argv[]) +{ + for (int i = 1; i < argc; i++) + { + if (std::string(argv[i]) == "--ant-walk-worker") + { + return true; + } + } + return false; +} + +inline int run(int argc, const char* argv[]) +{ + Options options; + if (!parseOptions(argc, argv, options)) + { + fprintf(stderr, "[ant-walker] --ant-walk-worker needs --socket --threads \n"); + return 2; + } + + signal(SIGPIPE, SIG_IGN); +#if defined(__linux__) + // The node is the only reason this process exists; outliving it would just squat the socket. + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) + { + return 0; + } +#endif + + const int listenFd = openListener(options.socketPath.c_str()); + if (listenFd < 0) + { + return 3; + } + + for (unsigned int i = 0; i < options.threadCount; i++) + { + gPoolOfWorkers.threads.emplace_back(runWorker, i); + } + fprintf(stderr, "[ant-walker] listening on %s, %u threads, pid %d\n", options.socketPath.c_str(), options.threadCount, (int)getpid()); + fflush(stderr); + + while (!gPoolOfWorkers.stopping.load(std::memory_order_acquire)) + { + struct pollfd entry; + entry.fd = listenFd; + entry.events = POLLIN; + entry.revents = 0; + const int ready = poll(&entry, 1, POLL_SLICE_MS); +#if defined(__linux__) + if (getppid() == 1) + { + break; + } +#endif + if (ready <= 0) + { + continue; + } + const int connectionFd = accept(listenFd, nullptr, nullptr); + if (connectionFd < 0) + { + continue; + } + serveConnection(connectionFd, options.threadCount); + } + + gPoolOfWorkers.stopping.store(true, std::memory_order_release); + gPoolOfWorkers.queueSignal.notify_all(); + for (std::thread& worker : gPoolOfWorkers.threads) + { + worker.join(); + } + close(listenFd); + unlink(options.socketPath.c_str()); + return 0; +} +} + +#else + +namespace AntWalkerWorker +{ +inline bool requested(int, const char*[]) { return false; } +inline int run(int, const char*[]) { return 0; } +} + +#endif // ANT_WALKER && !_WIN32 diff --git a/src/extensions/http/controller/rpc_stats_controller.h b/src/extensions/http/controller/rpc_stats_controller.h index 7453d545..cfa7be22 100644 --- a/src/extensions/http/controller/rpc_stats_controller.h +++ b/src/extensions/http/controller/rpc_stats_controller.h @@ -140,6 +140,15 @@ RPC_ROUTE("GET", "/v1/fork-stats") return r; } +// Ant walker health: separates a walker chewing through work from one up but delivering nothing. +RPC_ROUTE("GET", "/v1/ant-walker") +{ + (void)req; + RpcResp r; + r.body = AntWalker::statsJson(); + return r; +} + // The full durable record of every unforkable tick (not a recent ring) — one line per skipped fork. RPC_ROUTE("GET", "/v1/unforkable-ticks") { diff --git a/src/extensions/supervisor_shim.h b/src/extensions/supervisor_shim.h index e8015433..b22c27a2 100644 --- a/src/extensions/supervisor_shim.h +++ b/src/extensions/supervisor_shim.h @@ -16,6 +16,7 @@ #include inline char gSidecarPort[16] = "41841"; // node http port -> sidecar listen + unix-socket key +inline char gAntWalkerThreads[16] = "0"; // matches the node default; 0 keeps the walker unspawned // Forward a stop signal to the children so the container/service stops promptly. static void shimForwardSignal(int sig) @@ -49,8 +50,38 @@ static pid_t shimForkSidecar() #endif } +// Re-exec self as the ant walker, a sibling of the node. Spawned here, not by the node, so it +// outlives a rollback promotion. +static pid_t shimForkAntWalker() +{ + if (std::atoi(gAntWalkerThreads) <= 0) + return -1; + + const pid_t supervisorPid = getpid(); + pid_t walkerPid = fork(); + if (walkerPid != 0) + return walkerPid; + prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0); + if (getppid() != supervisorPid) + _exit(0); + + char self[512]; + ssize_t pathLength = readlink("/proc/self/exe", self, sizeof(self) - 1); + if (pathLength <= 0) + _exit(127); + self[pathLength] = 0; + + char socketPath[128]; + snprintf(socketPath, sizeof(socketPath), "/tmp/qubic-antwalk-%s.sock", gSidecarPort); + execl(self, "qubic-ant-walker", "--ant-walk-worker", "--socket", socketPath, "--threads", gAntWalkerThreads, (char*)nullptr); + // Not fatal for the node: without a walker the backlog is simply paid on demand as before. + fprintf(stderr, "[shim] could not exec the ant walker (%s), running without it\n", strerror(errno)); + fflush(stderr); + _exit(127); +} + // True while any child other than the sidecar exists (i.e. the node lineage is still alive). -static bool shimHasNodeChild(pid_t sidecar) +static bool shimHasNodeChild(pid_t sidecar, pid_t antWalker) { char path[64]; snprintf(path, sizeof(path), "/proc/self/task/%d/children", (int)getpid()); @@ -61,7 +92,7 @@ static bool shimHasNodeChild(pid_t sidecar) bool hasNodeChild = false; while (fscanf(childrenFile, "%d", &childPid) == 1) { - if (childPid != (int)sidecar) + if (childPid != (int)sidecar && childPid != (int)antWalker) { hasNodeChild = true; break; @@ -85,12 +116,19 @@ static inline void runUnderSupervisor(int argc, const char** argv) std::strncpy(gSidecarPort, argv[i + 1], sizeof(gSidecarPort) - 1); gSidecarPort[sizeof(gSidecarPort) - 1] = 0; } + if (std::string(argv[i]) == "--ant-walker-threads" && i + 1 < argc) + { + std::strncpy(gAntWalkerThreads, argv[i + 1], sizeof(gAntWalkerThreads) - 1); + gAntWalkerThreads[sizeof(gAntWalkerThreads) - 1] = 0; + } } if (prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0) // can't subreap: run node inline return; pid_t sidecar = shimForkSidecar(); + pid_t antWalker = shimForkAntWalker(); + int antWalkerRestarts = 0; pid_t node = fork(); if (node < 0) @@ -121,14 +159,30 @@ static inline void runUnderSupervisor(int argc, const char** argv) sidecar = shimForkSidecar(); // RPC must not stay down: restart it continue; } + if (antWalker > 0 && reapedPid == antWalker) + { + sleep(1); // a squatted socket would hot-loop the respawn + // An unrunnable walker would otherwise respawn once a second forever. + if (++antWalkerRestarts > 5) + { + fprintf(stderr, "[shim] ant walker died %d times, leaving it down\n", antWalkerRestarts); + fflush(stderr); + antWalker = -1; + continue; + } + antWalker = shimForkAntWalker(); + continue; + } lastStatus = status; if (WIFSIGNALED(status)) sawSignal = true; - if (!shimHasNodeChild(sidecar)) + if (!shimHasNodeChild(sidecar, antWalker)) break; // node lineage drained -> shim exits } if (sidecar > 0) kill(sidecar, SIGTERM); + if (antWalker > 0) + kill(antWalker, SIGTERM); _exit(sawSignal ? 1 : (WIFEXITED(lastStatus) ? WEXITSTATUS(lastStatus) : 1)); } diff --git a/src/platform/concurrency.h b/src/platform/concurrency.h index 41670cbe..8aa653be 100644 --- a/src/platform/concurrency.h +++ b/src/platform/concurrency.h @@ -40,6 +40,8 @@ namespace ForkCensus { std::atomic depth{ 0 }; std::atomic what{ nullptr }; + // Every LockGuard lock reports the same `what`, so only the address names the offender. + std::atomic where{ nullptr }; std::atomic live{ 0 }; // 0 = free/reusable, 1 = owned by a live thread }; inline Slot gSlots[MAX_THREADS]; @@ -99,13 +101,14 @@ namespace ForkCensus } } - inline void enter(const char* what) + inline void enter(const char* what, const volatile void* where = nullptr) { if (tlLockSlot < 0) claimSlot(); if (tlLockSlot < 0) return; // registry full (>MAX_THREADS live): best-effort, this thread uncounted gSlots[tlLockSlot].what.store(what, std::memory_order_relaxed); + gSlots[tlLockSlot].where.store(where, std::memory_order_relaxed); gSlots[tlLockSlot].depth.fetch_add(1, std::memory_order_relaxed); } inline void leave() @@ -131,6 +134,22 @@ namespace ForkCensus } return heldDepth < 0 ? 0 : heldDepth; } + inline const volatile void* offenderAddress() + { + const int selfSlot = tlLockSlot; + int slotCount = gCount.load(std::memory_order_acquire); + if (slotCount > MAX_THREADS) + slotCount = MAX_THREADS; + for (int slotIndex = 0; slotIndex < slotCount; slotIndex++) + { + if (slotIndex != selfSlot && gSlots[slotIndex].depth.load(std::memory_order_relaxed) > 0) + { + return gSlots[slotIndex].where.load(std::memory_order_relaxed); + } + } + return nullptr; + } + inline const char* offenderName() { if (gOverflow.load(std::memory_order_acquire)) @@ -157,6 +176,7 @@ namespace ForkCensus { gSlots[i].depth.store(0, std::memory_order_relaxed); gSlots[i].what.store(nullptr, std::memory_order_relaxed); + gSlots[i].where.store(nullptr, std::memory_order_relaxed); gSlots[i].live.store(0, std::memory_order_relaxed); } gCount.store(0, std::memory_order_release); @@ -165,9 +185,9 @@ namespace ForkCensus } } -inline void forkCensusEnter(const char* what) +inline void forkCensusEnter(const char* what, const volatile void* where = nullptr) { - ForkCensus::enter(what); + ForkCensus::enter(what, where); } inline void forkCensusLeave() @@ -185,6 +205,11 @@ inline const char* forkCensusOffender() return ForkCensus::offenderName(); } +inline const volatile void* forkCensusOffenderAddress() +{ + return ForkCensus::offenderAddress(); +} + inline void forkCensusResetForChildPromote() { ForkCensus::resetForChildPromote(); @@ -199,7 +224,7 @@ inline bool gForkCensus = true; do { \ while (_InterlockedCompareExchange8(&lock, 1, 0)) \ _mm_pause(); \ - forkCensusEnter(#lock " @ " __FILE__); \ + forkCensusEnter(#lock " @ " __FILE__, &lock); \ } while (0) #ifdef NDEBUG @@ -232,7 +257,7 @@ class BusyWaitingTracker while (_InterlockedCompareExchange8(&lock, 1, 0)) \ bwt.pause(); \ } \ - forkCensusEnter(#lock " @ " __FILE__); \ + forkCensusEnter(#lock " @ " __FILE__, &lock); \ } while (0) #endif @@ -240,7 +265,7 @@ class BusyWaitingTracker // Try to acquire lock and return if successful (without blocking) #define TRY_ACQUIRE(lock) \ (_InterlockedCompareExchange8(&lock, 1, 0) == 0 \ - ? (forkCensusEnter(#lock " @ " __FILE__), true) \ + ? (forkCensusEnter(#lock " @ " __FILE__, &lock), true) \ : false) // Release lock diff --git a/src/qubic.cpp b/src/qubic.cpp index 0086be9a..ff42cd69 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -714,27 +714,45 @@ static bool getAntAnchorDigestForRebuild(unsigned int tick, m256i& out) return recomputeAntAnchorDigest(tick, out); } +namespace AntWalker +{ +inline void preemptClaim(unsigned int index); +} + +// A background walker job holds its claim for a whole walk, so a rebuild the tick needs would queue +// behind it. Asking repeatedly is free: the walker only hands back a job old enough to be stuck. +static constexpr unsigned int ANT_ANN_CLAIM_PREEMPT_POLLS = 250; + +// Waiting costs what the walk costs; walking it here as well would pay that twice. +static AntColonyBpp9000T::AnnClaim waitForAnnClaim(unsigned int idx) +{ + for (unsigned int polls = 1; gAntColony.isAnnClaimHeld(idx); polls++) + { + sleepMilliseconds(20); + if (polls % ANT_ANN_CLAIM_PREEMPT_POLLS == 0) + { + AntWalker::preemptClaim(idx); + } + } + return gAntColony.tryClaimAnn(idx); +} + // Rebuilds one record's network. Its parent must already have one, or be the root. static bool materialiseOneAntRecord(unsigned long long processorNumber, unsigned int idx) { - const AntColonyBpp9000T::AnnClaim claim = gAntColony.tryClaimAnn(idx); + AntColonyBpp9000T::AnnClaim claim = gAntColony.tryClaimAnn(idx); + while (claim == AntColonyBpp9000T::AnnClaimBusy) + { + claim = waitForAnnClaim(idx); + } if (claim == AntColonyBpp9000T::AnnClaimReady) { return true; } - if (claim == AntColonyBpp9000T::AnnClaimInvalid) + if (claim != AntColonyBpp9000T::AnnClaimOwned) { return false; } - if (claim == AntColonyBpp9000T::AnnClaimBusy) - { - // Waiting costs what the walk costs; walking it here as well would pay that twice. - while (gAntColony.isAnnClaimHeld(idx)) - { - sleepMilliseconds(20); - } - return gAntColony.isAnnMaterialised(idx); - } // Owned from here on, so every exit below either publishes or releases the claim. const unsigned long long rebuildStart = __rdtsc(); @@ -767,9 +785,15 @@ static bool materialiseOneAntRecord(unsigned long long processorNumber, unsigned return false; } + // The cache every other scoring path consults, so a rebuild never re-walks a score we hold. AntColonyBpp9000T::Ann& childAnn = gAntRebuildChildScratch[processorNumber]; - const unsigned int rebuiltScore = score->computeAntChildScore(processorNumber, parentAnn, - rec->pubkey, rec->nonce, anchorDigest, childAnn); + const AntColonyBpp9000T::ReplayKey replayKey = makeAntReplayKey(rec->pubkey, rec->nonce, rec->parentRef, anchorDigest); + unsigned int rebuiltScore; + if (!gAntColony.tryGetReplayScore(replayKey, rebuiltScore, childAnn)) + { + rebuiltScore = score->computeAntChildScore(processorNumber, parentAnn, rec->pubkey, rec->nonce, anchorDigest, childAnn); + gAntColony.putReplayScore(replayKey, rebuiltScore, childAnn); + } // This walk is the computation the record was admitted without. A disagreement means the acceptance // was wrong, so publish nothing: a network built for a score no other node holds is worse than none. @@ -860,6 +884,10 @@ static bool ensureAntRecordAnn(unsigned long long processorNumber, unsigned int return (rec != nullptr) && gAntColony.annOfNonRoot(*rec, out); } +#include "extensions/ant_colony_maintenance.h" +#include "extensions/ant_walker_worker.h" +#include "extensions/ant_walker_client.h" + // A pool miner's solution, arriving over BroadcastMessage static void queueAntSolution(unsigned long long processorNumber, const m256i& computorPublicKey, const AntSolutionBroadcastPayload& payload) @@ -969,8 +997,11 @@ static void antColonyBeginEpoch() #ifndef NDEBUG gAntDebugPrintBudget = ANT_DEBUG_PRINTS_PER_EPOCH; #endif + AntWalker::quiesceBegin(); gAntPendingSolutions.reset(); gAntColony.beginEpoch(score->currentRandomSeed, system.initialTick); + AntWalker::onEpochBegin(); + AntWalker::quiesceEnd(); gAntColony.setErrorThreshold((unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000)); // Every identity's root derives from this one value, so a node that seeded differently builds a @@ -9490,6 +9521,7 @@ static void deinitialize() fastTxWindow.deinit(); gAntPendingSolutions.deinit(); + AntWalker::stop(); gAntColony.deinit(); if (score) @@ -10620,6 +10652,13 @@ static void tickForkChildPromote(unsigned int strictUntilTick) const bool shadowClean = gShadow.purgeOrphans(); ts.resetSwapPinsForChildPromote(); forkCensusResetForChildPromote(); + const unsigned int releasedAntClaims = AntColonyMaintenance::releaseInheritedClaims(gAntColony); + AntWalker::restartAfterPromote(); + if (releasedAntClaims) + { + fprintf(stderr, "[FORK] CHILD: released %u inherited ant network claims\n", releasedAntClaims); + fflush(stderr); + } if (!shadowClean) { forceVerifySolutions = true; @@ -10811,8 +10850,8 @@ static void bspForkPoint() { const char* offendingLock = forkCensusOffender(); ForkStats::onForkSkipped(ForkStats::CENSUS, (unsigned)system.tick, offendingLock ? offendingLock : "?"); - fprintf(stderr, "[FORK] census: non-BSP thread holds '%s' -> skip fork, run tick %u strict\n", offendingLock ? offendingLock : "?", - (unsigned)system.tick); + fprintf(stderr, "[FORK] census: non-BSP thread holds '%s' (%p) -> skip fork, run tick %u strict\n", offendingLock ? offendingLock : "?", + (const void*)forkCensusOffenderAddress(), (unsigned)system.tick); fflush(stderr); quiescence.release(); tickFork::gChildPid = -1; @@ -10841,6 +10880,7 @@ static void bspForkPoint() { // CHILD BSP: block until parent's verdict, then become the node. quiescence.abandonInChild(); + AntWalker::closeInheritedSocket(); close(tickFork::gPipe[1]); const auto childCommand = tickForkControl::readChildCommand(tickFork::gPipe[0], (unsigned)system.tick + tickFork::gForkWindowK); if (childCommand.action == tickForkControl::ChildAction::Retire) @@ -11761,6 +11801,8 @@ void processArgs(int argc, const char* argv[]) { ("fbas-warmup", "TEST: publish this many valid ant solutions before switching to the --fbas mode", cxxopts::value()->default_value("0")) ("fbas-gap", "TEST: minimum ticks between ant publishes; a gap wider than the fork window makes each window retire", cxxopts::value()->default_value("0")) ("ant-debug", "Trace ant-colony accepts, over-accepts and network rebuilds (budgeted per epoch)", cxxopts::value()) + ("ant-walker-threads", "Ant network walks handed to the walker sidecar (0=off)", cxxopts::value()->default_value("0")) + ("ant-walker-debug", "Trace every ant walker job and result", cxxopts::value()) #if defined(__linux__) && !defined(LITE_WASM_SC) ("verify-fork-rollback", "TEST: assert fork re-run reproduces quorum digest", cxxopts::value()) ("fork-force-fork", "TEST: fork every tick (exercise MATCH path)", cxxopts::value()) @@ -11982,6 +12024,18 @@ void processArgs(int argc, const char* argv[]) { + std::to_string(ANT_DEBUG_PRINTS_PER_EPOCH) + " lines per epoch"); } + { + const unsigned int antWalkerThreads = result["ant-walker-threads"].as(); + char antWalkerSocket[128]; + snprintf(antWalkerSocket, sizeof(antWalkerSocket), "/tmp/qubic-antwalk-%d.sock", httpPort); + AntWalker::configure(antWalkerSocket, antWalkerThreads, result.count("ant-walker-debug") > 0); + if (antWalkerThreads > 0) + { + logColorToScreen("INFO", "Ant walker sidecar enabled, " + std::to_string(antWalkerThreads) + + " threads, socket " + antWalkerSocket); + } + } + if (result.count("rebuild-tx-hashmap")) { rebuildTxHashmap = true; @@ -12386,6 +12440,11 @@ int main(int argc, const char* argv[]) return tickStorageScan::scan(); } #endif + // The walker, not a node. _exit so static destructors never run against uninitialised globals. + if (AntWalkerWorker::requested(argc, argv)) + { + _exit(AntWalkerWorker::run(argc, argv)); + } #if defined(__linux__) && !defined(NO_RPC) && !defined(LITE_WASM_SC) int rpcProxyExitCode = 0; if (runRpcProxyIfRequested(argc, argv, rpcProxyExitCode)) @@ -12402,6 +12461,7 @@ int main(int argc, const char* argv[]) #if defined(__linux__) && !defined(NO_RPC) && !defined(LITE_WASM_SC) startRpcServices(); #endif + AntWalker::start(); #if defined(LITE_WASM_SC) && !defined(NO_RPC) // Wasm testnet serves HTTP in-process; the unix-socket/sidecar stack is compiled out. QubicHttpServer::start(httpPort); diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 6a93959e..0db4fc19 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -6,6 +6,7 @@ // The bound colony, not the bare template: these tests check bpp9000's binding as well as the rules. #include "../src/mining/ant_colony/ant_colony_bpp9000.h" +#include "../src/extensions/ant_colony_maintenance.h" #include @@ -1146,3 +1147,102 @@ TEST(TestAntColonyExport, BeginEpochClearsIt) EXPECT_EQ(header.entryCount, 0u); EXPECT_EQ(header.solutionCount, 0u); } + +// A child of an existing node committed on trust: no network, the way an AUX node stores one. +static long long commitChildWithoutAnn(AntColonyBpp9000T* colony, const m256i& owner, const SolutionRef& parentRef, unsigned int score, + unsigned int txIdx, unsigned long long nonceSeed, unsigned int tick = 100000) +{ + const AntSolutionRecord* parentRec = nullptr; + if (colony->tryGetParent(parentRef, &parentRec) != ValidityResult::Valid) + { + return ANT_INVALID_INDEX; + } + + AntCommitInput in; + in.pubkey = owner; + in.nonce = makeKey(nonceSeed); + in.parentRef = parentRef; + in.selfRef.tick = tick; + in.selfRef.solutionIndexInTick = txIdx; + in.anchorTick = tick; + in.publishTick = tick; + + const long long landsAt = (long long)colony->solutionCount(); + if (colony->commit(in, parentRec, score, nullptr, 0, true) != ValidityResult::Valid) + { + return ANT_INVALID_INDEX; + } + return landsAt; +} + +// A claim held at fork time has no owner in the child, and the on-demand waiter would spin on it. +TEST(TestAntColonyMaintenance, PromoteReleasesAnInheritedClaim) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(41); + const long long idx = commitRootChildWithoutAnn(colony, me, 3800, 0, 901); + ASSERT_NE(idx, ANT_INVALID_INDEX); + const unsigned int slot = (unsigned int)idx; + + ASSERT_EQ(colony->tryClaimAnn(slot), AntColonyBpp9000T::AnnClaimOwned); + ASSERT_TRUE(colony->isAnnClaimHeld(slot)); + + EXPECT_EQ(AntColonyMaintenance::releaseInheritedClaims(*colony), 1u); + EXPECT_FALSE(colony->isAnnClaimHeld(slot)); + // Retryable, not merely unclaimed: a Busy here is the hang the sweep exists to prevent. + EXPECT_EQ(colony->tryClaimAnn(slot), AntColonyBpp9000T::AnnClaimOwned); + colony->releaseAnnClaim(slot); + + EXPECT_EQ(AntColonyMaintenance::releaseInheritedClaims(*colony), 0u); +} + +// A rebuild starts from the parent's network, so a record whose parent has none cannot be taken yet. +TEST(TestAntColonyMaintenance, RebuildableOnlyOnceTheParentHasItsNetwork) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(42); + const long long parentIdx = commitRootChildWithoutAnn(colony, me, 3900, 0, 902); + ASSERT_NE(parentIdx, ANT_INVALID_INDEX); + SolutionRef parentRef; + parentRef.tick = 100000; + parentRef.solutionIndexInTick = 0; + const long long childIdx = commitChildWithoutAnn(colony, me, parentRef, 3800, 1, 903); + ASSERT_NE(childIdx, ANT_INVALID_INDEX); + + // The root is closed-form, so the parent is takeable immediately and the child is not. + EXPECT_TRUE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)parentIdx)); + EXPECT_FALSE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)childIdx)); + + AntColonyBpp9000T::Ann ann; + setMem(&ann, sizeof(ann), 0); + unsigned int annHash; + KangarooTwelve(&ann, sizeof(ann), &annHash, sizeof(annHash)); + ASSERT_EQ(colony->tryClaimAnn((unsigned int)parentIdx), AntColonyBpp9000T::AnnClaimOwned); + colony->publishAnn((unsigned int)parentIdx, ann, annHash); + + // Materialised records are done, and the level below is now unblocked. + EXPECT_FALSE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)parentIdx)); + EXPECT_TRUE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)childIdx)); +} + +// Two rebuilders must not walk the same record: the claim is what keeps the second one moving on. +TEST(TestAntColonyMaintenance, AClaimedRecordIsNotOfferedAgain) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(43); + const long long idx = commitRootChildWithoutAnn(colony, me, 3800, 0, 904); + ASSERT_NE(idx, ANT_INVALID_INDEX); + + EXPECT_TRUE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)idx)); + ASSERT_EQ(colony->tryClaimAnn((unsigned int)idx), AntColonyBpp9000T::AnnClaimOwned); + EXPECT_FALSE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)idx)); + + colony->releaseAnnClaim((unsigned int)idx); + EXPECT_TRUE(AntColonyMaintenance::isRebuildableNow(*colony, (unsigned int)idx)); +}