feat: add bounded NomadNet SD page cache

This commit is contained in:
torlando-agent[bot]
2026-08-17 05:48:30 +00:00
parent da55131c44
commit 16af3b53af
24 changed files with 2773 additions and 114 deletions
@@ -0,0 +1,38 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#include "NomadNetStorageSD.h"
#ifdef ARDUINO
#include <SD.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
namespace Hardware { namespace TDeck {
using UI::LXMF::NomadNet::StorageResult;
namespace { StorageResult ioResult(){return errno==ENOSPC?StorageResult::FULL:StorageResult::IO_ERROR;} }
NomadNetStorageSD::NomadNetStorageSD()=default;
NomadNetStorageSD::~NomadNetStorageSD(){abortWrite();endRead();endList();abortWrite();}
bool NomadNetStorageSD::cardPresentLocked(){return SD.cardType()!=CARD_NONE;}
bool NomadNetStorageSD::mountedPath(const char*p,char*out,std::size_t cap){if(!p||p[0]!='/'||std::strstr(p,".."))return false;const int n=std::snprintf(out,cap,"/sd%s",p);return n>0&&static_cast<std::size_t>(n)<cap;}
bool NomadNetStorageSD::parentsLocked(const char*p){char part[192]={};const auto n=std::strlen(p);if(n>=sizeof(part))return false;std::memcpy(part,p,n+1);for(std::size_t i=1;i<n;++i)if(part[i]=='/'){part[i]=0;if(!SD.exists(part)&&!SD.mkdir(part))return false;part[i]='/';}return true;}
void NomadNetStorageSD::poisonWrite(){if(write_fd_>=0)abort_pending_=true;writing_=false;}
StorageResult NomadNetStorageSD::serviceAbortLocked(){if(!abort_pending_)return StorageResult::OK;const bool ok=write_fd_<0||::close(write_fd_)==0;write_fd_=-1;abort_pending_=false;writing_=false;if(!ok)healthy_=false;return ok?StorageResult::OK:StorageResult::IO_ERROR;}
bool NomadNetStorageSD::isAvailable()const{if(!healthy_||!SDAccess::is_ready()||!SDAccess::acquire_bus(100))return false;auto*self=const_cast<NomadNetStorageSD*>(this);const bool ok=self->serviceAbortLocked()==StorageResult::OK&&cardPresentLocked();SDAccess::release_bus();return ok;}
StorageResult NomadNetStorageSD::beginRead(const char*p,std::uint32_t&s){if(!healthy_||!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;auto cleanup=serviceAbortLocked();if(cleanup!=StorageResult::OK){SDAccess::release_bus();return cleanup;}if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}char mp[196];if(!mountedPath(p,mp,sizeof(mp))){SDAccess::release_bus();return StorageResult::INVALID_ARGUMENT;}struct stat st{};errno=0;if(::stat(mp,&st)!=0){auto r=errno==ENOENT?StorageResult::MISS:ioResult();SDAccess::release_bus();return r;}if(st.st_size<0||static_cast<std::uint64_t>(st.st_size)>UINT32_MAX){SDAccess::release_bus();return StorageResult::TOO_LARGE;}read_=SD.open(p,FILE_READ);if(!read_){SDAccess::release_bus();return StorageResult::IO_ERROR;}s=st.st_size;SDAccess::release_bus();return StorageResult::OK;}
StorageResult NomadNetStorageSD::readChunk(std::uint8_t*out,std::size_t cap,std::size_t&n){n=0;if(!healthy_)return StorageResult::IO_ERROR;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;if(!cardPresentLocked()){read_.close();SDAccess::release_bus();return StorageResult::UNAVAILABLE;}if(!read_){SDAccess::release_bus();return StorageResult::INVALID_STATE;}n=read_.read(out,cap);const bool failed=n==0&&read_.available();SDAccess::release_bus();return failed?StorageResult::NO_PROGRESS:StorageResult::OK;}
StorageResult NomadNetStorageSD::endRead(){if(!read_)return StorageResult::OK;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;read_.close();SDAccess::release_bus();return StorageResult::OK;}
StorageResult NomadNetStorageSD::beginWrite(const char*p){if(!healthy_||!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;auto cleanup=serviceAbortLocked();if(cleanup!=StorageResult::OK){SDAccess::release_bus();return cleanup;}if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}if(!parentsLocked(p)){SDAccess::release_bus();return StorageResult::IO_ERROR;}char mp[196];if(!mountedPath(p,mp,sizeof(mp))){SDAccess::release_bus();return StorageResult::INVALID_ARGUMENT;}errno=0;write_fd_=::open(mp,O_WRONLY|O_CREAT|O_TRUNC,0666);writing_=write_fd_>=0;auto r=writing_?StorageResult::OK:ioResult();SDAccess::release_bus();return r;}
StorageResult NomadNetStorageSD::writeChunk(const std::uint8_t*d,std::size_t z,std::size_t&n){n=0;if(!healthy_)return StorageResult::IO_ERROR;if(!SDAccess::acquire_bus(100)){poisonWrite();return StorageResult::BUSY;}if(!cardPresentLocked()){poisonWrite();SDAccess::release_bus();return StorageResult::UNAVAILABLE;}if(!writing_||write_fd_<0){SDAccess::release_bus();return StorageResult::INVALID_STATE;}errno=0;const auto w=::write(write_fd_,d,z);n=w<0?0:static_cast<std::size_t>(w);auto r=w<0?ioResult():n==0?StorageResult::NO_PROGRESS:n<z?StorageResult::PARTIAL_WRITE:StorageResult::OK;if(r!=StorageResult::OK)poisonWrite();SDAccess::release_bus();return r;}
StorageResult NomadNetStorageSD::commitWrite(){if(!healthy_)return StorageResult::IO_ERROR;if(!SDAccess::acquire_bus(100)){poisonWrite();return StorageResult::BUSY;}if(abort_pending_||!writing_||write_fd_<0){serviceAbortLocked();SDAccess::release_bus();return StorageResult::INVALID_STATE;}if(!cardPresentLocked()){poisonWrite();serviceAbortLocked();SDAccess::release_bus();return StorageResult::UNAVAILABLE;}errno=0;const bool synced=::fsync(write_fd_)==0;const int sync_error=errno;const bool closed=::close(write_fd_)==0;write_fd_=-1;writing_=false;errno=sync_error;auto r=synced&&closed?StorageResult::OK:ioResult();SDAccess::release_bus();return r;}
StorageResult NomadNetStorageSD::abortWrite(){if(write_fd_<0&&!abort_pending_)return StorageResult::OK;if(!SDAccess::acquire_bus(100)){poisonWrite();return StorageResult::BUSY;}abort_pending_=true;auto r=serviceAbortLocked();SDAccess::release_bus();return r;}
StorageResult NomadNetStorageSD::remove(const char*p){if(!healthy_)return StorageResult::IO_ERROR;if(!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;auto c=serviceAbortLocked();if(c!=StorageResult::OK){SDAccess::release_bus();return c;}if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}char mp[196];if(!mountedPath(p,mp,sizeof(mp))){SDAccess::release_bus();return StorageResult::INVALID_ARGUMENT;}errno=0;const bool ok=::unlink(mp)==0;const int saved_errno=errno;SDAccess::release_bus();if(ok)return StorageResult::OK;errno=saved_errno;return errno==ENOENT?StorageResult::MISS:ioResult();}
StorageResult NomadNetStorageSD::rename(const char*a,const char*b){if(!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;auto c=serviceAbortLocked();if(c!=StorageResult::OK){SDAccess::release_bus();return c;}if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}if(!SD.exists(a)){SDAccess::release_bus();return StorageResult::MISS;}/* Replacement policy belongs to the generation-aware cache. A generic rename must never destroy its destination before publication succeeds. */if(SD.exists(b)){SDAccess::release_bus();return StorageResult::INVALID_STATE;}const bool ok=SD.rename(a,b);SDAccess::release_bus();return ok?StorageResult::OK:StorageResult::IO_ERROR;}
StorageResult NomadNetStorageSD::stat(const char*p,std::uint32_t&s){if(!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;char mp[196];if(!mountedPath(p,mp,sizeof(mp))){SDAccess::release_bus();return StorageResult::INVALID_ARGUMENT;}struct stat st{};errno=0;const bool ok=::stat(mp,&st)==0;if(!ok){auto r=errno==ENOENT?StorageResult::MISS:ioResult();SDAccess::release_bus();return r;}if(st.st_size<0||static_cast<std::uint64_t>(st.st_size)>UINT32_MAX){SDAccess::release_bus();return StorageResult::TOO_LARGE;}s=st.st_size;SDAccess::release_bus();return StorageResult::OK;}
StorageResult NomadNetStorageSD::beginList(const char*p){auto prior=endList();if(prior!=StorageResult::OK)return prior;if(!SDAccess::is_ready())return StorageResult::UNAVAILABLE;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}char mp[196];if(!mountedPath(p,mp,sizeof(mp))||std::strlen(p)>=sizeof(list_base_)){SDAccess::release_bus();return StorageResult::INVALID_ARGUMENT;}struct stat st{};errno=0;if(::stat(mp,&st)!=0){const auto r=errno==ENOENT?StorageResult::OK:ioResult();SDAccess::release_bus();return r;}if(!S_ISDIR(st.st_mode)){SDAccess::release_bus();return StorageResult::INVALID_STATE;}errno=0;list_dir_=::opendir(mp);if(!list_dir_){const auto r=ioResult();SDAccess::release_bus();return r;}std::strcpy(list_base_,p);SDAccess::release_bus();return StorageResult::OK;}
StorageResult NomadNetStorageSD::nextList(char*out,std::size_t cap,bool&done){done=false;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;if(!cardPresentLocked()){SDAccess::release_bus();return StorageResult::UNAVAILABLE;}if(!list_dir_){done=true;SDAccess::release_bus();return StorageResult::OK;}struct dirent*entry=nullptr;do{errno=0;entry=::readdir(list_dir_);if(!entry){const auto saved=errno;done=saved==0;SDAccess::release_bus();errno=saved;return done?StorageResult::OK:ioResult();}}while(std::strcmp(entry->d_name,".")==0||std::strcmp(entry->d_name,"..")==0);const int n=std::snprintf(out,cap,"%s/%s",list_base_,entry->d_name);SDAccess::release_bus();return n>0&&static_cast<std::size_t>(n)<cap?StorageResult::OK:StorageResult::TOO_LARGE;}
StorageResult NomadNetStorageSD::endList(){if(!list_dir_)return StorageResult::OK;if(!SDAccess::acquire_bus(100))return StorageResult::BUSY;errno=0;const bool ok=::closedir(list_dir_)==0;list_dir_=nullptr;list_base_[0]=0;SDAccess::release_bus();return ok?StorageResult::OK:ioResult();}
}}
#endif
@@ -0,0 +1,37 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#ifndef HARDWARE_TDECK_NOMADNET_STORAGE_SD_H
#define HARDWARE_TDECK_NOMADNET_STORAGE_SD_H
#include "UI/LXMF/NomadNetStorage.h"
#ifdef ARDUINO
#include "SDAccess.h"
#include <FS.h>
#include <dirent.h>
namespace Hardware { namespace TDeck {
class NomadNetStorageSD final : public UI::LXMF::NomadNet::NomadNetStorage {
public:
NomadNetStorageSD(); ~NomadNetStorageSD() override;
bool isAvailable() const override;
UI::LXMF::NomadNet::StorageResult beginRead(const char*,std::uint32_t&) override;
UI::LXMF::NomadNet::StorageResult readChunk(std::uint8_t*,std::size_t,std::size_t&) override;
UI::LXMF::NomadNet::StorageResult endRead() override;
UI::LXMF::NomadNet::StorageResult beginWrite(const char*) override;
UI::LXMF::NomadNet::StorageResult writeChunk(const std::uint8_t*,std::size_t,std::size_t&) override;
UI::LXMF::NomadNet::StorageResult commitWrite() override;
UI::LXMF::NomadNet::StorageResult abortWrite() override;
UI::LXMF::NomadNet::StorageResult remove(const char*) override;
UI::LXMF::NomadNet::StorageResult rename(const char*,const char*) override;
UI::LXMF::NomadNet::StorageResult stat(const char*,std::uint32_t&) override;
UI::LXMF::NomadNet::StorageResult beginList(const char*) override;
UI::LXMF::NomadNet::StorageResult nextList(char*,std::size_t,bool&) override;
UI::LXMF::NomadNet::StorageResult endList() override;
private:
fs::File read_;DIR* list_dir_=nullptr;char list_base_[128]={};int write_fd_=-1;bool writing_=false,abort_pending_=false,healthy_=true;
static bool cardPresentLocked();static bool mountedPath(const char*,char*,std::size_t);
static bool parentsLocked(const char*);
UI::LXMF::NomadNet::StorageResult serviceAbortLocked();
void poisonWrite();
};
}}
#endif
#endif
+3 -2
View File
@@ -10,7 +10,7 @@
namespace UI::LXMF::NomadNet {
enum class UserActionKind : uint8_t { OPEN, SUBMIT, SAVE, IDENTIFY, BACK, HOME };
enum class UserActionKind : uint8_t { OPEN, RELOAD, SUBMIT, SAVE, IDENTIFY, BACK, HOME };
struct UserAction {
static constexpr std::size_t MAX_TARGET_BYTES = 511;
@@ -47,7 +47,8 @@ public:
_terminal_kind = kind;
_terminal_pending = true;
return true;
} else if (_terminal_pending && kind == UserActionKind::OPEN) {
} else if (_terminal_pending &&
(kind == UserActionKind::OPEN || kind == UserActionKind::RELOAD)) {
return false;
}
if (kind == UserActionKind::SAVE) {
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#ifndef UI_LXMF_NOMADNET_CACHE_H
#define UI_LXMF_NOMADNET_CACHE_H
#include "NomadNetMemory.h"
#include "NomadNetStorage.h"
#include <cstddef>
#include <cstdint>
#include <array>
#include <string>
#include <vector>
namespace UI { namespace LXMF { namespace NomadNet {
enum class RequestDataClass : std::uint8_t { NIL = 0, FIELDS = 1, FORM = 2 };
struct CacheKey {
CacheKey() = default;
CacheKey(const std::string& destination_value, const std::string& path_value,
RequestDataClass request_data_value)
: destination(destination_value), path(path_value), request_data(request_data_value) {}
std::string destination;
std::string path;
RequestDataClass request_data = RequestDataClass::NIL;
};
std::string canonical_cache_key(const CacheKey& key);
struct CacheEligibility {
bool successful = false;
bool valid = false;
bool partial = false;
bool malformed = false;
bool truncated = false;
bool error = false;
RequestDataClass request_data = RequestDataClass::NIL;
};
bool cache_eligible(const CacheEligibility& value);
struct CacheConfig {
static constexpr std::uint32_t DEFAULT_TTL_SECONDS = 12U * 60U * 60U;
static constexpr std::uint32_t MAX_TTL_SECONDS = 7U * 24U * 60U * 60U;
static constexpr std::size_t MAX_LOGICAL_ENTRIES = 32;
static constexpr std::size_t MAX_ON_DISK_BYTES = 2U * 1024U * 1024U;
static constexpr std::size_t MAX_SCAN_RECORDS = 96;
static constexpr std::size_t MAX_PAGE_STAGE_RESERVE = 66U * 1024U;
static constexpr std::size_t MAX_PATH_BYTES = 128;
std::size_t max_entries = MAX_LOGICAL_ENTRIES;
std::size_t max_bytes = MAX_ON_DISK_BYTES;
std::size_t max_scan_records = MAX_SCAN_RECORDS;
std::size_t max_stage_reserve = MAX_PAGE_STAGE_RESERVE;
std::size_t chunk_bytes = 1024;
std::uint64_t minimum_valid_epoch = 1;
};
struct CacheDirective {
bool present = false;
bool valid = true;
std::uint32_t ttl = CacheConfig::DEFAULT_TTL_SECONDS;
};
CacheDirective parse_cache_directive(const std::uint8_t* body, std::size_t size);
std::uint32_t cache_directive_ttl(const std::uint8_t* body, std::size_t size);
enum class CacheResult : std::uint8_t {
IDLE,
PENDING,
HIT,
MISS,
EXPIRED,
STORED,
BYPASS,
CANCELLED,
INVALID,
STORAGE_ERROR,
FULL
};
class NomadNetCache {
public:
explicit NomadNetCache(NomadNetStorage& storage, CacheConfig config = {});
CacheResult beginLookup(const CacheKey& key, std::uint64_t now, bool bypass = false);
CacheResult beginCommit(const CacheKey& key, const std::vector<std::uint8_t>& body,
std::uint64_t now, std::uint32_t ttl);
CacheResult beginCommit(const CacheKey& key, ExternalVector<std::uint8_t>&& body,
std::uint64_t now, std::uint32_t ttl);
CacheResult beginRecovery(std::uint64_t now, bool cleanup_stages = false);
CacheResult invalidate(const CacheKey& key);
void service();
void cancel();
bool busy() const { return operation_ != Operation::NONE; }
bool recoveryComplete() const { return recovery_complete_; }
CacheResult lastResult() const { return result_; }
bool takeBody(ExternalVector<std::uint8_t>& output);
std::size_t entryCount() const;
std::size_t totalBytes() const;
std::string debugMetadataPath(const CacheKey& key, unsigned generation) const;
std::string debugBodyPath(const CacheKey& key, unsigned generation) const;
unsigned debugGeneration() const { return generation_; }
static std::uint64_t hash(const std::uint8_t*, std::size_t,
std::uint64_t seed = 1469598103934665603ULL);
private:
enum class Operation {
NONE,
RECOVERY_BEGIN,
RECOVERY_NEXT,
RECOVERY_END,
RECOVERY_META,
RECOVERY_CLOSE_READ,
RECOVERY_STAT_BODY,
RECOVERY_CLEAN_STAGE,
LOOKUP_META,
LOOKUP_BODY,
PREPARE_REMOVE_INACTIVE_META,
PREPARE_REMOVE_INACTIVE_BODY,
PREPARE_REMOVE_STAGE_BODY,
PREPARE_REMOVE_STAGE_META,
PREPARE_BEGIN_BODY,
COMMIT_BODY,
COMMIT_BODY_SYNC,
COMMIT_META_BEGIN,
COMMIT_META,
COMMIT_META_SYNC,
VERIFY_BODY,
VERIFY_META,
PROMOTE_BODY,
PROMOTE_META,
EVICT_META_0,
EVICT_BODY_0,
EVICT_META_1,
EVICT_BODY_1,
INVALIDATE_META_0,
INVALIDATE_BODY_0,
INVALIDATE_META_1,
INVALIDATE_BODY_1,
CLEANUP_END_READ,
CLEANUP_ABORT_WRITE,
CLEANUP_STAGE_BODY,
CLEANUP_STAGE_META
};
struct Metadata {
std::uint64_t created = 0;
std::uint64_t expires = 0;
std::uint64_t body_hash = 0;
std::uint32_t body_size = 0;
std::uint32_t sequence = 0;
CacheKey key;
};
struct Entry {
CacheKey key;
std::uint64_t created = 0;
std::uint64_t expires = 0;
std::size_t body_bytes = 0;
std::size_t metadata_bytes = 0;
std::uint32_t sequence = 0;
unsigned generation = 0;
std::vector<std::uint8_t> metadata_record;
};
struct ScanRecord {
char stem[33] = {};
unsigned generation = 0;
};
struct StageRecord {
char path[CacheConfig::MAX_PATH_BYTES] = {};
};
NomadNetStorage& storage_;
CacheConfig config_;
Operation operation_ = Operation::NONE;
CacheResult result_ = CacheResult::IDLE;
CacheResult cleanup_result_ = CacheResult::IDLE;
CacheKey key_;
std::uint64_t now_ = 0;
unsigned generation_ = 0;
unsigned candidate_ = 0;
std::uint32_t sequence_ = 0;
ExternalVector<std::uint8_t> body_;
std::vector<std::uint8_t> io_;
std::vector<std::uint8_t> metadata_bytes_;
std::size_t offset_ = 0;
Metadata metadata_;
Metadata fallback_metadata_;
bool has_fallback_ = false;
unsigned fallback_generation_ = 0;
std::vector<Entry> entries_;
bool read_open_ = false;
bool write_open_ = false;
bool commit_job_ = false;
bool cleanup_stages_after_failure_ = false;
std::uint32_t read_size_ = 0;
unsigned metadata_generation_ = 0;
Metadata metadata_candidates_[2];
bool metadata_valid_[2] = {false, false};
std::vector<std::uint8_t> metadata_records_[2];
bool recovery_complete_ = false;
bool recovery_cleanup_stages_ = false;
std::size_t scan_seen_ = 0;
std::size_t scan_index_ = 0;
std::size_t cleanup_index_ = 0;
std::vector<ScanRecord> scan_records_;
std::vector<StageRecord> stage_records_;
char list_path_[CacheConfig::MAX_PATH_BYTES] = {};
std::size_t pending_metadata_bytes_ = 0;
std::vector<std::uint8_t> pending_metadata_record_;
bool recovery_retry_record_ = false;
bool namespace_authoritative_ = true;
std::vector<CacheKey> conflicted_keys_;
CacheKey eviction_key_;
bool eviction_pending_ = false;
int eviction_generation_ = -1;
CacheResult quota_result_ = CacheResult::STORED;
bool quota_recovery_ = false;
static constexpr std::size_t VERIFY_SCRATCH_BYTES = 1024;
std::array<std::uint8_t, VERIFY_SCRATCH_BYTES> verify_scratch_{};
std::uint64_t verify_hash_ = 1469598103934665603ULL;
bool verify_match_ = true;
std::string stageBody() const;
std::string stageMeta() const;
static bool sameKey(const CacheKey&, const CacheKey&);
static bool sequenceNewer(std::uint32_t, std::uint32_t);
static bool addWouldOverflow(std::size_t, std::size_t);
bool saneTime(std::uint64_t now) const;
bool encodeMetadata();
bool decodeMetadata(const std::vector<std::uint8_t>&, Metadata&) const;
StorageResult readStep(const std::string&, std::vector<std::uint8_t>&,
std::size_t limit, bool& complete);
StorageResult readBodyStep(const std::string&, std::size_t limit, bool& complete);
void fail(CacheResult);
void finishCleanup();
void lookupMetadata();
void lookupBody();
void finishCommit();
void beginQuotaEviction();
void finishEviction();
void finishRecovery();
void finishQuotaReconciliation();
bool removeStep(const std::string&, Operation);
bool parseOwnedMetadataPath(const char*, ScanRecord&) const;
bool parseOwnedStagePath(const char*, StageRecord&) const;
std::string scanMetadataPath() const;
std::string scanBodyPath() const;
std::size_t inactiveBytes(const CacheKey&, unsigned) const;
void eraseGeneration(const CacheKey&, unsigned);
};
}}} // namespace UI::LXMF::NomadNet
#endif
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#include "NomadNetCacheFlow.h"
namespace UI { namespace LXMF { namespace NomadNet {
CacheFlowState NomadNetCacheFlow::begin(const CacheKey& key, std::uint64_t now,
bool reload) {
cancel();
key_ = key;
now_ = now;
ExternalVector<std::uint8_t>().swap(page_);
status_ = reload ? "Invalidating cached page..." : "Checking page cache...";
invalidation_admitted_ = false;
if (reload) {
state_ = CacheFlowState::INVALIDATE;
if (!cache_.busy()) {
invalidation_admitted_ = cache_.invalidate(key_) == CacheResult::PENDING;
}
return state_;
}
cache_.beginLookup(key_, now, false);
state_ = cache_.busy() ? CacheFlowState::LOOKUP : CacheFlowState::NEED_LIVE;
if (state_ == CacheFlowState::NEED_LIVE) status_ = "Requesting page...";
return state_;
}
void NomadNetCacheFlow::service() {
if (state_ == CacheFlowState::CANCELLED || state_ == CacheFlowState::FAILED)
return;
if (cache_.busy()) cache_.service();
if (state_ == CacheFlowState::INVALIDATE) {
if (cache_.busy()) return;
if (!invalidation_admitted_) {
invalidation_admitted_ = cache_.invalidate(key_) == CacheResult::PENDING;
return;
}
if (cache_.lastResult() == CacheResult::MISS) {
state_ = CacheFlowState::NEED_LIVE;
status_ = "Requesting page...";
} else {
state_ = CacheFlowState::FAILED;
status_ = "Page cache invalidation failed";
}
return;
}
if (state_ != CacheFlowState::LOOKUP || cache_.busy()) return;
if (cache_.lastResult() == CacheResult::HIT && cache_.takeBody(page_)) {
state_ = CacheFlowState::READY;
status_ = "Cached page; current reachability not checked";
} else {
state_ = CacheFlowState::NEED_LIVE;
status_ = "Requesting page...";
}
}
bool NomadNetCacheFlow::acceptLive(const std::vector<std::uint8_t>& body,
const CacheEligibility& eligibility,
std::uint64_t now) {
if (state_ != CacheFlowState::NEED_LIVE || !eligibility.successful ||
!eligibility.valid || eligibility.partial || eligibility.malformed ||
eligibility.truncated || eligibility.error || body.empty()) {
state_ = CacheFlowState::FAILED;
status_ = "Malformed NomadNet response";
return false;
}
try {
page_.assign(body.begin(), body.end());
} catch (const std::bad_alloc&) {
state_ = CacheFlowState::FAILED;
status_ = "Page is too large for available memory";
return false;
}
state_ = CacheFlowState::READY;
status_ = "Page loaded (live)";
if (cache_eligible(eligibility)) {
const auto directive = parse_cache_directive(body.data(), body.size());
if (directive.valid && directive.ttl)
cache_.beginCommit(key_, body, now, directive.ttl);
else
cache_.invalidate(key_);
}
return true;
}
void NomadNetCacheFlow::cancel() {
if (cache_.busy()) cache_.cancel();
ExternalVector<std::uint8_t>().swap(page_);
if (state_ != CacheFlowState::IDLE) state_ = CacheFlowState::CANCELLED;
status_.clear();
}
bool NomadNetCacheFlow::takePage(ExternalVector<std::uint8_t>& output) {
if (!pageReady()) return false;
output.swap(page_);
return true;
}
}}} // namespace UI::LXMF::NomadNet
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#ifndef UI_LXMF_NOMADNET_CACHE_FLOW_H
#define UI_LXMF_NOMADNET_CACHE_FLOW_H
#include "NomadNetCache.h"
namespace UI { namespace LXMF { namespace NomadNet {
enum class CacheFlowState : std::uint8_t {
IDLE,
LOOKUP,
INVALIDATE,
NEED_LIVE,
READY,
FAILED,
CANCELLED
};
class NomadNetCacheFlow {
public:
explicit NomadNetCacheFlow(NomadNetCache& cache) : cache_(cache) {}
CacheFlowState begin(const CacheKey&, std::uint64_t now, bool reload);
void service();
bool acceptLive(const std::vector<std::uint8_t>&, const CacheEligibility&,
std::uint64_t now);
void cancel();
CacheFlowState state() const { return state_; }
bool pageReady() const { return state_ == CacheFlowState::READY && !page_.empty(); }
bool takePage(ExternalVector<std::uint8_t>&);
const std::string& status() const { return status_; }
private:
NomadNetCache& cache_;
CacheKey key_;
CacheFlowState state_ = CacheFlowState::IDLE;
ExternalVector<std::uint8_t> page_;
std::string status_;
std::uint64_t now_ = 0;
bool invalidation_admitted_ = false;
};
}}} // namespace UI::LXMF::NomadNet
#endif
+11 -2
View File
@@ -648,6 +648,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
offset = end == retained ? retained + 1 : end + 1;
if (first && line.rfind("#!c=", 0) == 0) {
doc.has_cache_directive = true;
const std::string number = line.substr(4);
if (!number.empty() && std::all_of(number.begin(), number.end(),
[](unsigned char c) { return std::isdigit(c) != 0; })) {
@@ -656,8 +657,16 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
if (parse_end && *parse_end == '\0') {
doc.cache_seconds = static_cast<uint32_t>(
std::min<unsigned long long>(parsed, MAX_CACHE_SECONDS));
} else doc.malformed = true;
} else doc.malformed = true;
} else {
doc.cache_seconds = 0;
doc.malformed = true;
doc.cache_directive_valid = false;
}
} else {
doc.cache_seconds = 0;
doc.malformed = true;
doc.cache_directive_valid = false;
}
continue;
}
if (!table_mode && line.rfind("#!bg=", 0) == 0) {
+3 -1
View File
@@ -111,7 +111,9 @@ struct Document {
std::vector<TableCell> table_cells;
std::vector<Run> table_runs;
std::vector<FormField> fields;
uint32_t cache_seconds = 0;
uint32_t cache_seconds = 12U * 60U * 60U;
bool has_cache_directive = false;
bool cache_directive_valid = true;
bool has_background = false;
uint32_t background = 0;
bool has_foreground = false;
+25 -5
View File
@@ -20,11 +20,13 @@ public:
Kind kind = Kind::NONE;
ExternalVector<uint8_t> data;
std::size_t transfer_size = 0;
std::uint32_t generation = 0;
};
void begin(const std::vector<uint8_t>& link_token) {
void begin(const std::vector<uint8_t>& link_token, std::uint32_t generation = 0) {
Guard guard(_lock);
_sealed = false;
if (generation != 0) _generation = generation;
if (_link_token == link_token &&
(_event.kind == Kind::LINK_ESTABLISHED || _event.kind == Kind::LINK_CLOSED)) return;
_link_token = link_token;
@@ -55,6 +57,7 @@ public:
_event.kind == Kind::FAILED ||
_event.kind == Kind::OVERSIZED)) return false;
_event.kind = established ? Kind::LINK_ESTABLISHED : Kind::LINK_CLOSED;
_event.generation = _generation;
_event.data.clear();
_event.transfer_size = 0;
return true;
@@ -71,10 +74,22 @@ public:
set_oversized(size);
return true;
}
_event.kind = Kind::RESPONSE;
_event.transfer_size = transfer_size;
_event.data.clear();
if (size != 0) _event.data.assign(data, data + size);
try {
if (size != 0) _event.data.assign(data, data + size);
} catch (const std::bad_alloc&) {
// Callback context must never unwind through Reticulum. Discard any
// partial payload and publish a bounded terminal failure for the
// accepted request token instead.
_event.data.clear();
_event.kind = Kind::FAILED;
_event.generation = _generation;
_event.transfer_size = 0;
return true;
}
_event.kind = Kind::RESPONSE;
_event.generation = _generation;
_event.transfer_size = transfer_size;
return true;
}
@@ -86,6 +101,7 @@ public:
if (token != _request_token) return false;
if (_event.kind == Kind::OVERSIZED || _event.kind == Kind::RESPONSE) return false;
_event.kind = Kind::FAILED;
_event.generation = _generation;
_event.data.clear();
_event.transfer_size = 0;
return true;
@@ -100,6 +116,7 @@ public:
if (_event.kind == Kind::RESPONSE || _event.kind == Kind::FAILED ||
_event.kind == Kind::OVERSIZED) return false;
_event.kind = Kind::PROGRESS;
_event.generation = _generation;
_event.data.clear();
_event.transfer_size = transfer_size;
return true;
@@ -130,9 +147,10 @@ public:
// Open an explicit pre-arm window before constructing a Link. Some
// implementations can call back before begin() receives its token.
void prepare() {
void prepare(std::uint32_t generation = 0) {
Guard guard(_lock);
reset(false);
_generation = generation;
}
// Terminal cleanup can synchronously invoke RequestReceipt's failed
@@ -162,6 +180,7 @@ private:
void set_oversized(std::size_t transfer_size) {
_event.kind = Kind::OVERSIZED;
_event.generation = _generation;
_event.data.clear();
_event.transfer_size = transfer_size;
}
@@ -170,6 +189,7 @@ private:
bool _sealed = false;
std::vector<uint8_t> _link_token;
std::vector<uint8_t> _request_token;
std::uint32_t _generation = 0;
Event _event;
};
+1
View File
@@ -87,6 +87,7 @@ public:
bool truncated() const { return false; }
void clear() { _bytes.clear(); }
void release() { ExternalVector<uint8_t>().swap(_bytes); }
ExternalVector<uint8_t> take() { ExternalVector<uint8_t> result; result.swap(_bytes); return result; }
private:
ExternalVector<uint8_t> _bytes;
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Pyxis contributors
// SPDX-License-Identifier: MIT
#ifndef UI_LXMF_NOMADNET_STORAGE_H
#define UI_LXMF_NOMADNET_STORAGE_H
#include <cstddef>
#include <cstdint>
namespace UI { namespace LXMF { namespace NomadNet {
enum class StorageResult : std::uint8_t {
OK, MISS, UNAVAILABLE, BUSY, FULL, INVALID_ARGUMENT, INVALID_STATE,
TOO_LARGE, PARTIAL_WRITE, NO_PROGRESS, CORRUPT, IO_ERROR
};
inline bool storage_result_is_transient(StorageResult r) {
return r == StorageResult::BUSY || r == StorageResult::UNAVAILABLE;
}
inline bool storage_result_is_unavailable(StorageResult r) { return r == StorageResult::UNAVAILABLE; }
inline bool storage_result_is_write_failure(StorageResult r) {
return r == StorageResult::FULL || r == StorageResult::PARTIAL_WRITE ||
r == StorageResult::NO_PROGRESS || r == StorageResult::IO_ERROR;
}
/** SD-shaped filesystem seam. Domain code has no Arduino/FS/SD dependency. */
class NomadNetStorage {
public:
virtual ~NomadNetStorage() = default;
virtual bool isAvailable() const = 0;
virtual StorageResult beginRead(const char* path, std::uint32_t& size) = 0;
virtual StorageResult readChunk(std::uint8_t* output, std::size_t capacity, std::size_t& count) = 0;
virtual StorageResult endRead() = 0;
virtual StorageResult beginWrite(const char* path) = 0;
virtual StorageResult writeChunk(const std::uint8_t* data, std::size_t size, std::size_t& written) = 0;
virtual StorageResult commitWrite() = 0;
virtual StorageResult abortWrite() = 0;
virtual StorageResult remove(const char* path) = 0;
virtual StorageResult rename(const char* from, const char* to) = 0;
virtual StorageResult stat(const char* path, std::uint32_t& size) = 0;
virtual StorageResult beginList(const char* directory) = 0;
virtual StorageResult nextList(char* path, std::size_t capacity, bool& done) = 0;
virtual StorageResult endList() = 0;
};
}}}
#endif
+260 -75
View File
@@ -284,6 +284,8 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router,
_propagation_manager(nullptr),
_ble_interface(nullptr),
_initialized(false),
_nomad_cache(_nomad_storage),
_nomad_cache_flow(_nomad_cache),
_call_state(CallState::IDLE),
_call_loopback(false),
_lxst_audio(nullptr),
@@ -398,7 +400,7 @@ bool UIManager::init() {
_nomadnet_screen->set_back_callback([this]() { _nomad_actions.publish(NomadNet::UserActionKind::BACK, {}); });
_nomadnet_screen->set_home_callback([this]() { _nomad_actions.publish(NomadNet::UserActionKind::HOME, {}); });
_nomadnet_screen->set_reload_callback([this](const std::string& address) {
return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, address);
return _nomad_actions.publish(NomadNet::UserActionKind::RELOAD, address);
});
_nomadnet_screen->set_open_callback([this](const std::string& address) {
return _nomad_actions.publish(NomadNet::UserActionKind::OPEN, address);
@@ -941,6 +943,8 @@ void UIManager::show_nomadnet() {
void UIManager::navigate(Route route) {
const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET && route != Route::NOMADNET;
if (leaving_nomadnet) {
nomad_advance_navigation_generation();
_nomad_cache_flow.cancel();
_nomad_state = NomadState::IDLE;
_nomad_mailbox.seal();
}
@@ -970,6 +974,7 @@ void UIManager::back() {
return;
}
if (_navigation.current() == Route::NOMADNET) {
nomad_advance_navigation_generation();
bool handled = false;
{
LVGL_LOCK();
@@ -981,6 +986,7 @@ void UIManager::back() {
_nomad_directory_refresh_pending.store(true, std::memory_order_release);
return;
}
_nomad_cache_flow.cancel();
_nomad_state = NomadState::IDLE;
_nomad_mailbox.seal();
}
@@ -996,6 +1002,8 @@ void UIManager::back() {
void UIManager::home() {
const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET;
if (leaving_nomadnet) {
nomad_advance_navigation_generation();
_nomad_cache_flow.cancel();
_nomad_state = NomadState::IDLE;
_nomad_mailbox.seal();
}
@@ -1956,6 +1964,11 @@ void UIManager::nomad_update_user_actions() {
nomad_heap_checkpoint("action-before-open");
nomad_open(target);
break;
case NomadNet::UserActionKind::RELOAD:
// Use the exact current history entry and its retained request bytes;
// reload never appends history and always bypasses the cache once.
nomad_reload();
break;
case NomadNet::UserActionKind::SUBMIT: {
std::string submit_target;
NomadNet::ExternalVector<uint8_t> submission_data;
@@ -2055,6 +2068,7 @@ bool UIManager::test_nomad_open(const std::string& address) {
void UIManager::test_nomad_status() const {
const char* state = "IDLE";
switch (_nomad_state) {
case NomadState::CACHE: state = "CACHE"; break;
case NomadState::PATH: state = "PATH"; break;
case NomadState::LINK: state = "LINK"; break;
case NomadState::REQUEST: state = "REQUEST"; break;
@@ -2149,6 +2163,39 @@ bool UIManager::nomad_refresh_path_after_link_failure() {
return true;
}
uint32_t UIManager::nomad_advance_navigation_generation() {
++_nomad_navigation_generation;
if (_nomad_navigation_generation == 0) ++_nomad_navigation_generation;
return _nomad_navigation_generation;
}
bool UIManager::nomad_supersede_transport(const std::string& destination_hex) {
// Reconcile callback and Reticulum ownership before cache lookup. This does
// not touch the rendered model: a cache miss clears it only when live I/O
// actually begins, while a hit replaces it atomically at publication.
RouterLock router_lock;
if (!router_lock.acquired()) return false;
_nomad_state = NomadState::IDLE;
_nomad_deadline_ms = 0;
_nomad_mailbox.seal();
const bool retain_link = _nomad_link &&
_nomad_link.status() == Type::Link::ACTIVE &&
_nomad_destination_hash &&
_nomad_destination_hash.toHex() == destination_hex;
if (!retain_link && _nomad_link && _nomad_link.status() != Type::Link::CLOSED)
_nomad_link.teardown();
nomad_release_request();
_nomad_request = RequestReceipt(Type::NONE);
_nomad_response.release();
if (!retain_link) {
_nomad_link = Link(Type::NONE);
_nomad_link_identified = false;
_nomad_destination_hash = Bytes();
}
return true;
}
void UIManager::nomad_open(const std::string& address, bool add_history,
int32_t restore_logical_scroll, bool preserve_submission) {
if (!preserve_submission) {
@@ -2202,6 +2249,13 @@ void UIManager::nomad_open(const std::string& address, bool add_history,
_nomadnet_screen->set_status("Form history exceeds available memory");
return;
}
nomad_advance_navigation_generation();
_nomad_cache_flow.cancel();
if (!nomad_supersede_transport(parsed.destination_hex)) {
LVGL_LOCK();
_nomadnet_screen->set_status("Navigation is busy; try again");
return;
}
_nomad_url=parsed;
_nomad_pending_scroll=-1;
{
@@ -2212,14 +2266,8 @@ void UIManager::nomad_open(const std::string& address, bool add_history,
return;
}
RouterLock router_lock;
if (!router_lock.acquired()) {
NomadNet::clear_encoded_form(_nomad_submission_data);
_nomad_submission_ready = false;
return;
}
_nomad_pending_scroll=restore_logical_scroll;
nomad_heap_checkpoint("open-locked");
nomad_heap_checkpoint("open-owner");
const uint8_t* history_request = _nomad_submission_ready
? _nomad_submission_data.data() : nullptr;
const std::size_t history_request_size = _nomad_submission_ready
@@ -2232,32 +2280,67 @@ void UIManager::nomad_open(const std::string& address, bool add_history,
_nomadnet_screen->set_status("Form history exceeds available memory");
return;
}
nomad_advance_navigation_generation();
_nomad_cache_flow.cancel();
if (!nomad_supersede_transport(parsed.destination_hex)) {
NomadNet::clear_encoded_form(_nomad_submission_data);
_nomad_submission_ready = false;
LVGL_LOCK();
_nomadnet_screen->set_status("Navigation is busy; try again");
return;
}
_nomad_url = parsed;
_nomad_request_data_class = _nomad_submission_ready
? NomadNet::RequestDataClass::FORM
: parsed.fields.empty() ? NomadNet::RequestDataClass::NIL
: NomadNet::RequestDataClass::FIELDS;
const NomadNet::CacheKey cache_key{
parsed.destination_hex, parsed.path, _nomad_request_data_class};
const double wall_time = Utilities::OS::time();
const uint64_t cache_now = wall_time >= 1609459200.0
? static_cast<uint64_t>(wall_time) : 0;
const bool bypass = _nomad_cache_bypass_once;
_nomad_cache_bypass_once = false;
_nomad_cache_generation = _nomad_navigation_generation;
if (_nomad_cache_flow.begin(cache_key, cache_now, bypass) ==
NomadNet::CacheFlowState::LOOKUP) {
_nomad_state = NomadState::CACHE;
LVGL_LOCK();
_nomadnet_screen->set_status("Checking SD page cache...");
return;
}
nomad_begin_live_transport();
}
void UIManager::nomad_begin_live_transport() {
RouterLock router_lock;
if (!router_lock.acquired()) {
// Preserve the currently published page and retry from the deterministic
// owner loop. No mailbox or transport callback is armed in this state.
_nomad_state = NomadState::LIVE_PENDING;
return;
}
{
// Validation must precede destructive UI cleanup so a malformed manual
// address cannot discard the current page or directory. Keep cleanup
// before any retained-Link request or new transport construction.
// Cache lookup and Router contention are deliberately non-destructive.
// Clear the old model only after live transport admission owns Router.
LVGL_LOCK();
nomad_heap_checkpoint("action-before-navigation");
_nomadnet_screen->begin_navigation(parsed.str());
_nomadnet_screen->begin_navigation(_nomad_url.str());
nomad_heap_checkpoint("action-after-navigation");
}
const bool same_destination = _nomad_state == NomadState::IDLE &&
_nomad_link && _nomad_link.status() == Type::Link::ACTIVE &&
!_nomad_url.destination_hex.empty() &&
parsed.destination_hex == _nomad_url.destination_hex;
const bool same_destination = _nomad_link &&
_nomad_link.status() == Type::Link::ACTIVE &&
_nomad_destination_hash &&
_nomad_destination_hash.toHex() == _nomad_url.destination_hex;
if (same_destination) {
_nomad_response.clear();
_nomad_request_policy.reset();
_nomad_url = parsed;
{
LVGL_LOCK();
_nomadnet_screen->set_address(parsed.str());
_nomadnet_screen->set_address(_nomad_url.str());
_nomadnet_screen->set_status("Requesting page...");
}
// Reopen only the bounded early-request callback window. The retained
// Link is already ACTIVE and remains owned by this owner task.
_nomad_mailbox.prepare();
_nomad_mailbox.prepare(_nomad_navigation_generation);
nomad_send_request();
return;
}
@@ -2268,19 +2351,14 @@ void UIManager::nomad_open(const std::string& address, bool add_history,
_nomad_link_identified = false;
_nomad_request = RequestReceipt(Type::NONE);
_nomad_response.clear();
nomad_heap_checkpoint("open-cleared");
_nomad_request_policy.reset();
_nomad_url = parsed;
_nomad_destination_hash = Bytes();
_nomad_destination_hash.assignHex(parsed.destination_hex.c_str());
nomad_heap_checkpoint("open-state-ready");
_nomad_destination_hash.assignHex(_nomad_url.destination_hex.c_str());
{
LVGL_LOCK();
_nomadnet_screen->set_address(parsed.str());
_nomadnet_screen->set_address(_nomad_url.str());
_nomadnet_screen->set_status("Discovering path...");
}
nomad_heap_checkpoint("open-ui-ready");
if (Transport::has_path(_nomad_destination_hash)) nomad_start_link();
else {
Transport::request_path(_nomad_destination_hash);
@@ -2300,6 +2378,7 @@ void UIManager::nomad_reload() {
_nomadnet_screen->set_status("Saved form request exceeds available memory");
return;
}
_nomad_cache_bypass_once = true;
nomad_open(_nomad_history.current(), false, -1,
_nomad_history.current_has_request_data());
}
@@ -2340,11 +2419,11 @@ void UIManager::nomad_start_link() {
_nomadnet_screen->set_status("Identity does not match node address");
return;
}
_nomad_mailbox.prepare();
_nomad_mailbox.prepare(_nomad_navigation_generation);
nomad_heap_checkpoint("link-before-construct");
_nomad_link = Link(destination, on_nomad_link_established, on_nomad_link_closed);
nomad_heap_checkpoint("link-after-construct");
_nomad_mailbox.begin(token(_nomad_link.link_id()));
_nomad_mailbox.begin(token(_nomad_link.link_id()), _nomad_navigation_generation);
_nomad_state = NomadState::LINK;
_nomad_deadline_ms = millis() + NomadNet::RequestPolicy::LINK_WAIT_MS;
LVGL_LOCK();
@@ -2414,7 +2493,118 @@ void UIManager::nomad_send_request() {
_nomadnet_screen->set_status("Requesting page...");
}
bool UIManager::nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached) {
NomadNet::Document document;
try {
document = _nomad_parser.parse(reinterpret_cast<const char*>(data), size);
} catch (const std::bad_alloc&) {
LVGL_LOCK();
_nomadnet_screen->set_status("Page is too large for available memory");
return false;
}
if (document.malformed && document.blocks.empty()) {
LVGL_LOCK();
_nomadnet_screen->set_status("Page is not valid UTF-8/Micron");
return false;
}
return nomad_apply_page_document(document, cached);
}
bool UIManager::nomad_apply_page_document(const NomadNet::Document& document, bool cached) {
std::vector<std::string> heading_runs;
for (const auto& block : document.blocks) {
if (block.type != NomadNet::BlockType::HEADING) continue;
for (const auto& run : block.runs) heading_runs.push_back(run.text);
break;
}
const std::string title = NomadNet::page_title(_nomad_url.path, heading_runs);
if (_nomad_library.record_page(_nomad_url.str(), title,
static_cast<uint64_t>(Utilities::OS::time()))) _nomad_library_dirty = true;
const bool page_saved = _nomad_library.page_saved(_nomad_url.str());
bool applied = false;
bool anchor_resolved = true;
{
LVGL_LOCK();
if (cached) {
// Parsing and semantic admission succeeded while the prior page was
// still visible. Destructive replacement occurs only at publication.
_nomadnet_screen->begin_navigation(_nomad_url.str());
}
_nomadnet_screen->set_library(_nomad_library);
applied = _nomadnet_screen->set_page(document);
if (applied && _nomad_pending_scroll >= 0)
_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll);
else if (applied && _nomad_url.has_fragment)
anchor_resolved = _nomadnet_screen->jump_to_anchor(_nomad_url.fragment);
if (applied) {
_nomadnet_screen->set_page_saved(page_saved);
_nomadnet_screen->set_identify_enabled(
_nomad_library.node_identified(_nomad_url.destination_hex));
if (!anchor_resolved && !_nomad_url.fragment.empty()) {
const std::string status = "Unknown anchor: #" + _nomad_url.fragment;
_nomadnet_screen->set_status(status.c_str());
} else {
_nomadnet_screen->set_status(cached
? "Cached page; current reachability not checked"
: "Page loaded (live)");
}
}
}
_nomad_pending_scroll = -1;
return applied;
}
void UIManager::nomad_update() {
// Filesystem chunks are owner-loop work and are always serviced before the
// Router serialization domain. LVGL is taken only later to apply a complete,
// validated immutable page.
// Start deferred mutations before RouterLock. The response callback only
// transfers immutable PSRAM ownership while the router is serialized.
if (_nomad_cache_pending_invalidate &&
_nomad_cache_pending_generation != _nomad_navigation_generation) {
_nomad_cache_pending_invalidate = false;
}
if (!_nomad_cache_pending_body.empty() &&
_nomad_cache_pending_generation != _nomad_navigation_generation) {
NomadNet::ExternalVector<uint8_t>().swap(_nomad_cache_pending_body);
_nomad_cache_pending_now = 0;
_nomad_cache_pending_ttl = 0;
}
if (!_nomad_cache.busy() && _nomad_cache_pending_invalidate) {
_nomad_cache.invalidate(_nomad_cache_pending_key);
_nomad_cache_pending_invalidate = false;
}
if (!_nomad_cache.busy() && !_nomad_cache_pending_body.empty()) {
_nomad_cache.beginCommit(_nomad_cache_pending_key,
std::move(_nomad_cache_pending_body), _nomad_cache_pending_now,
_nomad_cache_pending_ttl);
_nomad_cache_pending_now = 0;
_nomad_cache_pending_ttl = 0;
}
_nomad_cache_flow.service();
if (_nomad_state == NomadState::CACHE) {
if (_nomad_cache_generation != _nomad_navigation_generation) {
_nomad_cache_flow.cancel();
_nomad_state = NomadState::IDLE;
return;
}
if (_nomad_cache_flow.state() == NomadNet::CacheFlowState::LOOKUP) return;
if (_nomad_cache_flow.state() == NomadNet::CacheFlowState::READY) {
NomadNet::ExternalVector<uint8_t> cached;
if (_nomad_cache_flow.takePage(cached) &&
nomad_apply_page_bytes(cached.data(), cached.size(), true)) {
_nomad_state = NomadState::IDLE;
return;
}
}
_nomad_state = NomadState::IDLE;
nomad_begin_live_transport();
return;
}
if (_nomad_state == NomadState::LIVE_PENDING) {
nomad_begin_live_transport();
return;
}
RouterLock router_lock;
if (!router_lock.acquired()) return;
const uint32_t now = millis();
@@ -2431,6 +2621,7 @@ void UIManager::nomad_update() {
NomadNet::AsyncMailbox::Event event;
if (!_nomad_mailbox.take(event)) return;
if (event.generation != _nomad_navigation_generation) return;
if (event.kind == NomadNet::AsyncMailbox::Kind::RESPONSE)
nomad_heap_checkpoint("response-taken");
switch (event.kind) {
@@ -2476,71 +2667,65 @@ void UIManager::nomad_update() {
document = _nomad_parser.parse(
reinterpret_cast<const char*>(bytes.data()), bytes.size());
} catch (const std::bad_alloc&) {
_nomad_response.clear();
_nomad_response.release();
nomad_stop_transport();
LVGL_LOCK();
_nomadnet_screen->set_status("Page is too large for available memory");
break;
}
nomad_heap_checkpoint("response-parsed");
if (!(document.malformed && document.blocks.empty())) {
std::vector<std::string> heading_runs;
for (const auto& block : document.blocks) {
if (block.type != NomadNet::BlockType::HEADING) continue;
for (const auto& run : block.runs) heading_runs.push_back(run.text);
break;
}
const std::string title = NomadNet::page_title(_nomad_url.path, heading_runs);
if (_nomad_library.record_page(_nomad_url.str(), title,
static_cast<uint64_t>(Utilities::OS::time())))
_nomad_library_dirty = true;
}
const bool page_saved = _nomad_library.page_saved(_nomad_url.str());
const bool valid_document = !document.malformed && !document.truncated &&
document.cache_directive_valid && !document.blocks.empty();
if (document.malformed && document.blocks.empty()) {
_nomad_response.release();
nomad_stop_transport();
LVGL_LOCK();
_nomadnet_screen->set_status("Page is not valid UTF-8/Micron");
break;
}
bool page_applied = false;
bool anchor_resolved = true;
{
LVGL_LOCK();
_nomadnet_screen->set_library(_nomad_library);
page_applied = _nomadnet_screen->set_page(document);
if(page_applied&&_nomad_pending_scroll>=0)
_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll);
else if(page_applied&&_nomad_url.has_fragment)
anchor_resolved=_nomadnet_screen->jump_to_anchor(_nomad_url.fragment);
}
_nomad_pending_scroll=-1;
if (page_applied) _nomad_response.release();
const bool page_applied = nomad_apply_page_document(document, false);
nomad_heap_checkpoint("response-page-applied");
if (!page_applied) {
_nomad_response.release();
nomad_stop_transport();
break;
}
// A close callback can race a terminal response and is deliberately
// suppressed so the valid page wins. Recheck the live Link after
// applying the page; retain only ACTIVE ownership, otherwise perform
// ordered full teardown instead of leaving a CLOSED Link attached.
if (_nomad_link && _nomad_link.status() == Type::Link::ACTIVE) {
const bool has_password = std::any_of(document.fields.begin(), document.fields.end(),
[](const NomadNet::FormField& field) {
return field.type == NomadNet::FormFieldType::PASSWORD;
});
const bool ordinary_nil =
_nomad_request_data_class == NomadNet::RequestDataClass::NIL;
const auto directive = NomadNet::parse_cache_directive(bytes.data(), bytes.size());
const double wall_time = Utilities::OS::time();
const uint64_t cache_now = wall_time >= 1609459200.0
? static_cast<uint64_t>(wall_time) : 0;
const NomadNet::CacheKey cache_key{
_nomad_url.destination_hex, _nomad_url.path,
_nomad_request_data_class};
if (ordinary_nil && valid_document && !has_password && cache_now &&
directive.valid && directive.ttl) {
_nomad_cache_pending_key = cache_key;
_nomad_cache_pending_body = _nomad_response.take();
_nomad_cache_pending_now = cache_now;
_nomad_cache_pending_ttl = directive.ttl;
_nomad_cache_pending_generation = event.generation;
} else {
_nomad_response.release();
if (ordinary_nil && (!directive.valid || directive.ttl == 0 || has_password))
{ _nomad_cache_pending_key = cache_key;
_nomad_cache_pending_generation = event.generation;
_nomad_cache_pending_invalidate = true; }
}
// The response wins a racing close callback. Retain only a currently
// active Link whose independent owner hash still matches this URL.
if (_nomad_link && _nomad_link.status() == Type::Link::ACTIVE &&
_nomad_destination_hash &&
_nomad_destination_hash.toHex() == _nomad_url.destination_hex) {
nomad_finish_request_keep_link();
} else {
nomad_stop_transport();
}
{
LVGL_LOCK();
_nomadnet_screen->set_page_saved(page_saved);
_nomadnet_screen->set_identify_enabled(
_nomad_library.node_identified(_nomad_url.destination_hex));
if(!anchor_resolved&&!_nomad_url.fragment.empty()){
const std::string status="Unknown anchor: #"+_nomad_url.fragment;
_nomadnet_screen->set_status(status.c_str());
}else{
_nomadnet_screen->set_status(document.truncated ? "Page loaded (truncated)" : "Page loaded");
}
}
break;
}
case NomadNet::AsyncMailbox::Kind::NONE:
+22 -1
View File
@@ -21,6 +21,8 @@
#include "NomadNetRequestPolicy.h"
#include "NomadNetActionMailbox.h"
#include "NomadNetLibrary.h"
#include "NomadNetCacheFlow.h"
#include "Hardware/TDeck/NomadNetStorageSD.h"
#include "ConversationListScreen.h"
#include "ChatScreen.h"
#include "ComposeScreen.h"
@@ -412,8 +414,22 @@ private:
NomadNet::ActionMailbox _nomad_actions;
NomadNet::ExternalVector<uint8_t> _nomad_submission_data;
bool _nomad_submission_ready = false;
NomadNet::RequestDataClass _nomad_request_data_class =
NomadNet::RequestDataClass::NIL;
NomadNet::Library _nomad_library;
NomadNet::RequestPolicy _nomad_request_policy;
Hardware::TDeck::NomadNetStorageSD _nomad_storage;
NomadNet::NomadNetCache _nomad_cache;
NomadNet::NomadNetCacheFlow _nomad_cache_flow;
bool _nomad_cache_bypass_once = false;
NomadNet::ExternalVector<uint8_t> _nomad_cache_pending_body;
NomadNet::CacheKey _nomad_cache_pending_key;
uint64_t _nomad_cache_pending_now = 0;
uint32_t _nomad_cache_pending_ttl = 0;
bool _nomad_cache_pending_invalidate = false;
uint32_t _nomad_navigation_generation = 0;
uint32_t _nomad_cache_generation = 0;
uint32_t _nomad_cache_pending_generation = 0;
RNS::HAnnounceHandler _nomad_announce_handler;
std::atomic<bool> _nomad_directory_refresh_pending{false};
@@ -424,7 +440,7 @@ private:
RNS::Link _nomad_link{RNS::Type::NONE};
bool _nomad_link_identified = false;
RNS::RequestReceipt _nomad_request{RNS::Type::NONE};
enum class NomadState { IDLE, PATH, LINK, REQUEST };
enum class NomadState { IDLE, CACHE, LIVE_PENDING, PATH, LINK, REQUEST };
std::atomic<NomadState> _nomad_state{NomadState::IDLE};
uint32_t _nomad_deadline_ms = 0;
int32_t _nomad_pending_scroll = -1;
@@ -439,6 +455,11 @@ private:
void nomad_reload();
bool nomad_restore_history_submission();
void nomad_update();
uint32_t nomad_advance_navigation_generation();
bool nomad_supersede_transport(const std::string& destination_hex);
void nomad_begin_live_transport();
bool nomad_apply_page_bytes(const uint8_t* data, std::size_t size, bool cached);
bool nomad_apply_page_document(const NomadNet::Document& document, bool cached);
void nomad_start_link();
void nomad_identify_link_if_configured();
void nomad_send_request();
+19 -3
View File
@@ -128,6 +128,16 @@ int main(int argc, char** argv) {
check("active link callback is accepted", mailbox.publish_link(new_link, true));
AsyncMailbox::Event event;
check("link event crosses mailbox", mailbox.take(event) && event.kind == AsyncMailbox::Kind::LINK_ESTABLISHED);
AsyncMailbox generated;
generated.prepare(41);
check("prepared callback captures its navigation generation",
generated.publish_link(new_link, true) && generated.take(event) &&
event.generation == 41);
generated.prepare(42);
generated.begin(new_link, 42);
check("new callback captures the replacement navigation generation",
generated.publish_link(new_link, true) && generated.take(event) &&
event.generation == 42);
AsyncMailbox early_link;
check("link callback may arrive before token arming", early_link.publish_link(new_link, true));
early_link.begin(new_link);
@@ -1114,11 +1124,15 @@ int main(int argc, char** argv) {
check("malformed table content has a readable fallback", doc.malformed && saw_unsupported);
auto later_cache = parser.parse("text\n#!c=99999999999999999999\nmore");
check("cache metadata is first-line-only", later_cache.cache_seconds == 0);
check("cache metadata is first-line-only",
!later_cache.has_cache_directive &&
later_cache.cache_seconds == 12U * 60U * 60U);
auto clamped_cache = parser.parse("#!c=99999999999999999999\ntext");
check("cache seconds clamps without overflow", clamped_cache.cache_seconds == DocumentParser::MAX_CACHE_SECONDS);
auto high_bit_digit = parser.parse(std::string("#!c=1") + char(0xff) + "\ntext");
check("cache digit validation is unsigned-char safe", high_bit_digit.cache_seconds == 0 && high_bit_digit.malformed);
check("cache digit validation is unsigned-char safe",
high_bit_digit.cache_seconds == 12U * 60U * 60U &&
high_bit_digit.malformed);
std::string invalid_utf8("ok\xF0\x28\x8C\x28", 6);
auto utf8_doc = parser.parse(invalid_utf8);
@@ -1144,7 +1158,9 @@ int main(int argc, char** argv) {
huge_doc.has_truncation(UI::LXMF::NomadNet::TruncationReason::DOCUMENT_BYTES) &&
UI::LXMF::NomadNet::truncation_notice(huge_doc) ==
"[Page truncated: source exceeds 64 KiB]");
check("parser never processes metadata beyond retained source", huge_doc.cache_seconds == 0);
check("parser never processes metadata beyond retained source",
!huge_doc.has_cache_directive &&
huge_doc.cache_seconds == 12U * 60U * 60U);
std::string long_line(DocumentParser::MAX_SOURCE_LINE_BYTES + 20, 'q');
auto line_doc = parser.parse(long_line);
check("source line capped", line_doc.truncated && !line_doc.blocks.empty() &&
+110 -25
View File
@@ -280,7 +280,8 @@ def test_nomadnet_table_renderer_is_bounded_and_virtualized():
assert "try {" in parse_guard
assert "catch (const std::bad_alloc&)" in parse_guard
assert "Page is too large for available memory" in parse_guard
assert "_nomad_response.clear()" in parse_guard
# Allocation failure must relinquish the full normalized PSRAM buffer.
assert "_nomad_response.release()" in manager
def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints():
@@ -312,7 +313,7 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints():
assert "_nomad_url.path,_nomad_url.fields" in open_page
assert "NomadNet::should_jump_locally" in open_page
local = open_page[open_page.index("NomadNet::should_jump_locally"):
open_page.index("RouterLock router_lock")]
open_page.index("_nomad_pending_scroll=restore_logical_scroll")]
assert "_nomadnet_screen->jump_to_anchor(parsed.fragment)" in local
assert "if(!resolved&&parsed.fragment.empty())return;" in local
assert "_nomad_history.current_request_data()" in local
@@ -321,20 +322,23 @@ def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints():
assert "begin_navigation" not in local
request = manager[manager.index("void UIManager::nomad_send_request()"):
manager.index("void UIManager::nomad_update()")]
manager.index("bool UIManager::nomad_apply_page_bytes")]
assert "_nomad_url.path.data()" in request
assert "fragment" not in request
response = manager[manager.index("case NomadNet::AsyncMailbox::Kind::RESPONSE:"):
manager.index("case NomadNet::AsyncMailbox::Kind::NONE:")]
assert "_nomadnet_screen->set_page(document)" in response
assert "_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)" in response
assert "_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)" in response
assert "if(page_applied&&_nomad_pending_scroll>=0)" in response
assert "else if(page_applied&&_nomad_url.has_fragment)" in response
assert (response.index("_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)") <
response.index("_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)"))
assert "Unknown anchor: #" in response
apply = manager[manager.index("bool UIManager::nomad_apply_page_document("):
manager.index("void UIManager::nomad_update()")]
assert "nomad_apply_page_document(document, false)" in response
assert "_nomadnet_screen->set_page(document)" in apply
assert "_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)" in apply
assert "_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)" in apply
assert "if (applied && _nomad_pending_scroll >= 0)" in apply
assert "else if (applied && _nomad_url.has_fragment)" in apply
assert (apply.index("_nomadnet_screen->restore_logical_scroll(_nomad_pending_scroll)") <
apply.index("_nomadnet_screen->jump_to_anchor(_nomad_url.fragment)"))
assert "Unknown anchor: #" in apply
assert "int32_t _nomad_pending_scroll" in manager_h
@@ -398,10 +402,30 @@ def test_nomadnet_page_body_uses_one_compact_custom_viewport():
def test_successful_page_application_releases_normalized_response():
manager = (INCLUDE / "UIManager.cpp").read_text()
apply_start = manager.index("bool page_applied = false;")
apply_end = manager.index("nomad_heap_checkpoint(\"response-page-applied\")", apply_start)
applied = manager[apply_start:apply_end]
assert "_nomad_response.release();" in applied
response = manager[manager.index("case NomadNet::AsyncMailbox::Kind::RESPONSE:"):
manager.index("case NomadNet::AsyncMailbox::Kind::NONE:")]
checkpoint = response.index('nomad_heap_checkpoint("response-page-applied")')
assert response.index("_nomad_response.take()", checkpoint) > checkpoint
assert response.index("_nomad_response.release()", checkpoint) > checkpoint
def test_cache_lookup_preserves_page_and_response_uses_captured_request_class():
manager = (INCLUDE / "UIManager.cpp").read_text()
header = (INCLUDE / "UIManager.h").read_text()
open_page = manager[manager.index("void UIManager::nomad_open("):
manager.index("void UIManager::nomad_reload()")]
lookup = open_page.index("_nomad_cache_flow.begin")
assert "begin_navigation" not in open_page[:lookup]
assert "_nomad_request_data_class" in header
assert open_page.index("_nomad_request_data_class = _nomad_submission_ready") < lookup
send = manager[manager.index("void UIManager::nomad_send_request()"):
manager.index("bool UIManager::nomad_apply_page_bytes")]
assert "_nomad_request_data_class =" not in send
assert "_nomad_submission_ready = false" in send
response = manager[manager.index("case NomadNet::AsyncMailbox::Kind::RESPONSE:"):
manager.index("case NomadNet::AsyncMailbox::Kind::NONE:")]
assert "_nomad_request_data_class == NomadNet::RequestDataClass::NIL" in response
assert "!_nomad_submission_ready" not in response
def test_directory_rebuild_has_one_final_focus_owner():
@@ -483,9 +507,15 @@ def test_nomadnet_validates_queued_addresses_before_navigation_teardown():
open_page = manager[manager.index("void UIManager::nomad_open("):
manager.index("void UIManager::nomad_reload()")]
parse = open_page.index("NomadNet::Url::parse")
navigation = open_page.index("_nomadnet_screen->begin_navigation(parsed.str())")
same_destination = open_page.index("const bool same_destination")
assert parse < navigation < same_destination
lookup = open_page.index("_nomad_cache_flow.begin")
assert parse < lookup
assert "begin_navigation" not in open_page[:lookup]
live = manager[manager.index("void UIManager::nomad_begin_live_transport()"):
manager.index("void UIManager::nomad_reload()")]
# A valid address must also preserve the current page until live transport
# serialization succeeds; lock contention is retried by the owner loop.
assert live.index("RouterLock router_lock;") < \
live.index("_nomadnet_screen->begin_navigation(_nomad_url.str())")
def test_launcher_transition_has_one_final_focus_owner():
@@ -533,6 +563,62 @@ def test_nomadnet_directory_uses_one_coherent_focus_style():
)
def test_nomadnet_cache_blocker_boundaries_are_closed():
manager = (INCLUDE / "UIManager.cpp").read_text()
header = (INCLUDE / "UIManager.h").read_text()
cache = (INCLUDE / "NomadNetCache.cpp").read_text()
storage = (ROOT / "lib/tdeck_ui/Hardware/TDeck/NomadNetStorageSD.cpp").read_text()
# Every navigation owns transport/mailbox/cache actions by a monotonically
# advancing generation, and old transport is reconciled before a hit publishes.
assert "_nomad_navigation_generation" in header
open_page = manager[manager.index("void UIManager::nomad_open("):
manager.index("void UIManager::nomad_reload()")]
assert "nomad_supersede_transport" in open_page
cache_hit = manager[manager.index("if (_nomad_state == NomadState::CACHE)"):
manager.index("RouterLock router_lock", manager.index("if (_nomad_state == NomadState::CACHE)"))]
assert "_nomad_navigation_generation" in cache_hit
response = manager[manager.index("case NomadNet::AsyncMailbox::Kind::RESPONSE:"):
manager.index("case NomadNet::AsyncMailbox::Kind::NONE:")]
assert "event.generation" in manager
assert "_nomad_cache_pending_generation" in response
# Commit readback is one fixed bounded streaming scratch buffer, never a
# second full internal-heap vector while the PSRAM body remains owned.
verify = cache[cache.index("case Operation::VERIFY_BODY"):
cache.index("case Operation::VERIFY_META")]
assert "readStep(stageBody(), io_" not in verify
assert "verify_scratch_" in verify
assert "verify_hash_" in verify
# Production directory enumeration must preserve errno fidelity instead of
# collapsing exists/openNextFile failures to missing/EOF.
listing = storage[storage.index("StorageResult NomadNetStorageSD::beginList"):]
assert "::opendir" in listing and "::readdir" in listing
assert "errno" in listing and "::closedir" in listing
assert "SD.exists(p)" not in listing
assert "openNextFile" not in listing
removal = storage[storage.index("StorageResult NomadNetStorageSD::remove"):
storage.index("StorageResult NomadNetStorageSD::rename")]
assert "mountedPath(p" in removal
assert "::unlink(" in removal
assert "errno==ENOENT?StorageResult::MISS:ioResult()" in removal
assert "SD.exists" not in removal and "SD.remove" not in removal
# Live admission is non-destructive until Router serialization succeeds.
# Contention leaves an explicit owner-loop state that retries deterministically.
live = manager[manager.index("void UIManager::nomad_begin_live_transport()"):
manager.index("void UIManager::nomad_reload()")]
assert live.index("RouterLock router_lock;") < live.index("begin_navigation")
assert "if (!router_lock.acquired())" in live
assert "_nomad_state = NomadState::LIVE_PENDING;" in live
update = manager[manager.index("void UIManager::nomad_update()"):
manager.index("void UIManager::on_nomad_link_established")]
assert "if (_nomad_state == NomadState::LIVE_PENDING)" in update
assert "nomad_begin_live_transport();" in update
def test_network_transition_has_one_final_focus_owner():
"""Network hide/show must not transiently leave the last row focused."""
screen = (INCLUDE / "NetworkScreen.cpp").read_text()
@@ -888,18 +974,17 @@ def test_nomadnet_same_destination_navigation_reuses_active_link():
"nomad_finish_request_keep_link();"
)]
assert "nomad_stop_transport();" in malformed
assert "page_applied = _nomadnet_screen->set_page(document);" in response
assert "nomad_apply_page_document(document, false)" in response
assert "if (!page_applied)" in response
assert response.index("_nomadnet_screen->set_page(document)") < response.index(
assert response.index("nomad_apply_page_document(document, false)") < response.index(
"nomad_finish_request_keep_link();"
)
retained = response[response.index("_nomadnet_screen->set_page(document)"):
response.index("set_page_saved(page_saved)")]
assert "_nomad_link.status() == Type::Link::ACTIVE" in retained
assert "nomad_stop_transport();" in retained
application_failure = response[response.index("if (!page_applied)"):
response.index("set_page_saved(page_saved)")]
response.index("const bool has_password")]
assert "nomad_stop_transport();" in application_failure
retained = response[response.index("_nomad_link.status() == Type::Link::ACTIVE"):]
assert "_nomad_destination_hash.toHex() == _nomad_url.destination_hex" in retained
assert "nomad_stop_transport();" in retained
def test_nomadnet_physical_test_hooks_are_isolated_and_owner_queued():
+196
View File
@@ -0,0 +1,196 @@
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <map>
#include <string>
#include <vector>
#include "NomadNetCache.h"
using namespace UI::LXMF::NomadNet;
struct MemoryStorage final:NomadNetStorage{
std::map<std::string,std::vector<uint8_t>> files;std::string active,remove_fail_substring,read_fail_path,stat_fail_path;size_t pos=0;bool writing=false;bool available=true,busy=false,full=false;size_t max_write=SIZE_MAX;int fail_rename_at=0,renames=0,commit_calls=0,abort_calls=0;mutable size_t operations=0;StorageResult remove_result=StorageResult::OK,read_fail_result=StorageResult::OK,stat_fail_result=StorageResult::OK;int remove_failures=0,end_read_failures=0,abort_failures=0;StorageResult list_result=StorageResult::OK;int next_list_fail_at=0;
bool isAvailable()const override{++operations;return available;}
StorageResult beginRead(const char*n,uint32_t&s)override{++operations;if(n==read_fail_path&&read_fail_result!=StorageResult::OK)return read_fail_result;if(!available)return StorageResult::UNAVAILABLE;if(busy)return StorageResult::BUSY;auto i=files.find(n);if(i==files.end())return StorageResult::MISS;active=n;pos=0;s=i->second.size();return StorageResult::OK;}
StorageResult readChunk(uint8_t*out,size_t cap,size_t&n)override{++operations;if(busy){n=0;return StorageResult::BUSY;}auto&i=files[active];n=std::min(cap,i.size()-pos);if(n)std::memcpy(out,i.data()+pos,n);pos+=n;return StorageResult::OK;}
StorageResult endRead()override{++operations;if(end_read_failures-->0)return StorageResult::BUSY;active.clear();return StorageResult::OK;}
StorageResult beginWrite(const char*n)override{++operations;if(!available)return StorageResult::UNAVAILABLE;if(busy)return StorageResult::BUSY;if(full)return StorageResult::FULL;active=n;files[active].clear();writing=true;return StorageResult::OK;}
StorageResult writeChunk(const uint8_t*d,size_t z,size_t&n)override{++operations;if(full){n=0;return StorageResult::FULL;}n=std::min(z,max_write);files[active].insert(files[active].end(),d,d+n);return n==z?StorageResult::OK:(n?StorageResult::PARTIAL_WRITE:StorageResult::NO_PROGRESS);}
StorageResult commitWrite()override{++operations;++commit_calls;writing=false;active.clear();return StorageResult::OK;}
StorageResult abortWrite()override{++operations;++abort_calls;if(abort_failures-->0)return StorageResult::BUSY;if(writing)files.erase(active);writing=false;active.clear();return StorageResult::OK;}
StorageResult remove(const char*n)override{++operations;if(remove_failures>0&&(remove_fail_substring.empty()||std::string(n).find(remove_fail_substring)!=std::string::npos)){--remove_failures;return remove_result;}return files.erase(n)?StorageResult::OK:StorageResult::MISS;}
StorageResult rename(const char*a,const char*b)override{++operations;if(fail_rename_at&&++renames==fail_rename_at)return StorageResult::IO_ERROR;auto i=files.find(a);if(i==files.end())return StorageResult::MISS;if(files.count(b))return StorageResult::INVALID_STATE;files[b]=i->second;files.erase(i);return StorageResult::OK;}
StorageResult stat(const char*n,uint32_t&s)override{++operations;if(n==stat_fail_path&&stat_fail_result!=StorageResult::OK)return stat_fail_result;auto i=files.find(n);if(i==files.end())return StorageResult::MISS;s=i->second.size();return StorageResult::OK;}
StorageResult beginList(const char*d)override{++operations;list.clear();for(auto&f:files)if(f.first.rfind(std::string(d)+"/",0)==0)list.push_back(f.first);li=0;return available?list_result:StorageResult::UNAVAILABLE;}
StorageResult nextList(char*n,size_t c,bool&done)override{++operations;if(next_list_fail_at&&static_cast<int>(li+1)==next_list_fail_at)return StorageResult::IO_ERROR;if(li==list.size()){done=true;return StorageResult::OK;}done=false;if(list[li].size()+1>c){++li;return StorageResult::TOO_LARGE;}std::memcpy(n,list[li].c_str(),list[li].size()+1);++li;return StorageResult::OK;}
StorageResult endList()override{++operations;return StorageResult::OK;}std::vector<std::string>list;size_t li=0;
};
static CacheKey key(const char*path="/page/index.mu"){return CacheKey{"0123456789abcdef0123456789abcdef",path,RequestDataClass::NIL};}
static void drain(NomadNetCache&c,MemoryStorage*s=nullptr){for(int i=0;i<1000&&c.busy();++i){const auto before=s?s->operations:0;c.service();if(s&&s->operations-before>1)throw std::runtime_error("more than one storage operation per service tick");}}
int main(){int f=0;auto ck=[&](bool x,const char*n){if(!x){++f;std::cerr<<"FAIL "<<n<<"\n";}};MemoryStorage s;CacheConfig cfg;cfg.max_entries=2;cfg.max_bytes=4096;cfg.max_scan_records=8;NomadNetCache c(s,cfg);drain(c,&s);const std::vector<uint8_t> body={'h','e','l','l','o'};
ck(canonical_cache_key(key())=="0123456789abcdef0123456789abcdef\n/page/index.mu\nnil","canonical key");
ck(cache_directive_ttl(body.data(),body.size())==CacheConfig::DEFAULT_TTL_SECONDS,"default 12h");const char no[]="#!c=0\nhello";ck(cache_directive_ttl((const uint8_t*)no,sizeof(no)-1)==0,"c zero disables");
ck(cache_eligible({true,true,false,false,false,false,RequestDataClass::NIL}),"ordinary success eligible");ck(!cache_eligible({true,true,false,false,false,false,RequestDataClass::FIELDS}),"request data bypass");ck(!cache_eligible({true,true,true,false,false,false,RequestDataClass::NIL}),"partial bypass");ck(!cache_eligible({true,false,false,false,false,false,RequestDataClass::NIL}),"malformed bypass");
ck(c.beginCommit(key(),body,100,60)==CacheResult::PENDING,"begin commit");drain(c,&s);ck(c.lastResult()==CacheResult::STORED,"commit stored");
ck(c.beginLookup(key(),159)==CacheResult::PENDING,"lookup start");drain(c,&s);ExternalVector<uint8_t> got;ck(c.takeBody(got)&&std::equal(got.begin(),got.end(),body.begin(),body.end())&&c.lastResult()==CacheResult::HIT,"fresh hit uses external ownership");
ck(c.beginLookup(key(),160)==CacheResult::PENDING,"ttl boundary start");drain(c);ck(c.lastResult()==CacheResult::EXPIRED,"ttl exact boundary expired");
ck(c.beginCommit(key(),body,100,60)==CacheResult::PENDING,"restore");drain(c);ck(c.beginLookup(key(),99)==CacheResult::PENDING,"back clock");drain(c);ck(c.lastResult()==CacheResult::MISS,"backward clock conservative miss");
ck(c.beginLookup(key(),0)==CacheResult::PENDING,"invalid clock");drain(c);ck(c.lastResult()==CacheResult::MISS,"invalid clock miss");
CacheKey form_key=key();form_key.request_data=RequestDataClass::FORM;const std::vector<uint8_t> empty_map={0x80};ck(c.beginLookup(form_key,159)==CacheResult::BYPASS&&empty_map.front()==0x80,"empty-map form request bypasses cache");
// Corrupt newest metadata and require prior generation fallback.
ck(c.beginCommit(key(),std::vector<uint8_t>{'o','l','d'},200,60)==CacheResult::PENDING,"old gen");drain(c);ck(c.beginCommit(key(),std::vector<uint8_t>{'n','e','w'},201,60)==CacheResult::PENDING,"new gen");drain(c);auto newest=c.debugMetadataPath(key(),c.debugGeneration());s.files[newest].resize(5);ck(c.beginLookup(key(),202)==CacheResult::PENDING,"corrupt lookup");drain(c);got.clear();const std::vector<uint8_t> old={'o','l','d'};ck(c.takeBody(got)&&std::equal(got.begin(),got.end(),old.begin(),old.end()),"corrupt newest falls back");
// Truncated body and key collision metadata are rejected.
auto bp=c.debugBodyPath(key(),c.debugGeneration());s.files[bp].resize(1);ck(c.beginLookup(key(),202)==CacheResult::PENDING,"truncated lookup");drain(c);ck(c.lastResult()!=CacheResult::HIT,"truncated rejected");
CacheKey collision=key("/page/other.mu");s.files[c.debugMetadataPath(collision,0)]=s.files[c.debugMetadataPath(key(),0)];ck(c.beginLookup(collision,202)==CacheResult::PENDING,"collision lookup");drain(c);ck(c.lastResult()!=CacheResult::HIT,"full key validates collision");
// Interrupted promotion keeps a valid prior generation.
MemoryStorage s2;NomadNetCache c2(s2,cfg);drain(c2,&s2);ck(c2.beginCommit(key(),body,300,60)==CacheResult::PENDING,"seed");drain(c2);s2.fail_rename_at=2;ck(c2.beginCommit(key(),std::vector<uint8_t>{'x'},301,60)==CacheResult::PENDING,"crash commit");drain(c2);ck(c2.lastResult()==CacheResult::STORAGE_ERROR,"rename crash reported");s2.fail_rename_at=0;ck(c2.beginLookup(key(),302)==CacheResult::PENDING,"fallback after crash");drain(c2);ck(c2.takeBody(got)&&std::equal(got.begin(),got.end(),body.begin(),body.end()),"prior generation survives");
// SD faults are cache misses to caller, never page failures.
s2.available=false;ck(c2.beginLookup(key(),302)==CacheResult::PENDING,"unavailable begins");drain(c2);ck(c2.lastResult()==CacheResult::BYPASS,"unavailable bypass");s2.available=true;s2.busy=true;ck(c2.beginLookup(key(),302)==CacheResult::PENDING,"busy begins");drain(c2);ck(c2.lastResult()==CacheResult::BYPASS,"busy bypass");
// Deterministic expired-first then oldest quota eviction.
MemoryStorage s3;NomadNetCache c3(s3,cfg);drain(c3,&s3);for(int i=0;i<3;i++){auto k=key((std::string("/page/")+char('a'+i)).c_str());ck(c3.beginCommit(k,body,400+i,i==0?1:100)==CacheResult::PENDING,"quota commit");drain(c3);}ck(c3.entryCount()<=2&&c3.totalBytes()<=cfg.max_bytes,"quotas bounded");ck(c3.beginLookup(key("/page/a"),500)==CacheResult::PENDING,"evicted lookup");drain(c3);ck(c3.lastResult()!=CacheResult::HIT,"expired evicted first");
// A fresh cache reconstructs both generations from only the exact namespace.
s2.busy=false;
s2.files["/other/cache/foreign.0.meta"]={1,2,3};
s2.files["/pyxis-nomadnet/cache/unknown.tmp"]={4,5,6};
const auto unknown=s2.files["/pyxis-nomadnet/cache/unknown.tmp"];
NomadNetCache rebooted(s2,cfg);
ck(rebooted.beginLookup(key(),159)==CacheResult::BYPASS,"navigation during recovery bypasses live");
drain(rebooted,&s2);
ck(rebooted.recoveryComplete(),"recovery completes");
ck(rebooted.entryCount()==1,"recovery reconstructs logical key");
ck(s2.files["/pyxis-nomadnet/cache/unknown.tmp"]==unknown&&s2.files.count("/other/cache/foreign.0.meta"),"unknown and foreign files untouched");
ck(rebooted.beginLookup(key(),302)==CacheResult::PENDING,"reboot lookup starts");drain(rebooted,&s2);ck(rebooted.lastResult()==CacheResult::HIT,"reboot record hits");
// Interrupted stage files are ignored, and only explicit valid-clock recovery cleans them.
s.files["/pyxis-nomadnet/cache/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.stage.body"]={9};
NomadNetCache staged(s,cfg);drain(staged,&s);
ck(s.files.count("/pyxis-nomadnet/cache/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.stage.body"),"boot scan preserves stage");
ck(staged.beginRecovery(0,true)==CacheResult::BYPASS,"invalid clock refuses cleanup");
ck(staged.beginRecovery(1000,true)==CacheResult::PENDING,"explicit recovery cleanup starts");drain(staged,&s);
ck(!s.files.count("/pyxis-nomadnet/cache/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.stage.body"),"explicit recovery cleans stage incrementally");
// Physical quota includes metadata, retained fallback, and candidate staging headroom.
MemoryStorage tight;CacheConfig tight_cfg=cfg;tight_cfg.max_bytes=400;tight_cfg.max_stage_reserve=160;NomadNetCache tc(tight,tight_cfg);drain(tc,&tight);
ck(tc.beginCommit(key(),std::vector<uint8_t>(40,'a'),100,60)==CacheResult::PENDING,"tight seed starts");drain(tc,&tight);const auto before=tight.files;
ck(tc.totalBytes()>40,"physical accounting includes metadata");
ck(tc.beginCommit(key(),std::vector<uint8_t>(80,'b'),101,60)==CacheResult::FULL,"headroom rejects oversized candidate");
ck(tight.files==before,"headroom failure preserves old files");
ck(tc.beginLookup(key(),102)==CacheResult::PENDING,"old lookup after full");drain(tc,&tight);ck(tc.lastResult()==CacheResult::HIT,"old record survives full");
// Both retained generations and both metadata files count as physical bytes.
MemoryStorage retained;NomadNetCache rc(retained,cfg);drain(rc,&retained);
ck(rc.beginCommit(key(),std::vector<uint8_t>(20,'a'),1000,100)==CacheResult::PENDING,"retained first");drain(rc,&retained);const auto one_generation=rc.totalBytes();
ck(rc.beginCommit(key(),std::vector<uint8_t>(30,'b'),1001,100)==CacheResult::PENDING,"retained second");drain(rc,&retained);
ck(rc.entryCount()==1&&rc.totalBytes()>one_generation+30,"both generations physically accounted");
// No logical victim is touched until a candidate is fully promoted.
MemoryStorage victims;NomadNetCache vc(victims,cfg);drain(vc,&victims);
ck(vc.beginCommit(key("/page/v1"),body,2000,100)==CacheResult::PENDING,"victim one seed");drain(vc,&victims);
ck(vc.beginCommit(key("/page/v2"),body,2001,100)==CacheResult::PENDING,"victim two seed");drain(vc,&victims);
const auto victim_files=victims.files;victims.renames=0;victims.fail_rename_at=1;
ck(vc.beginCommit(key("/page/candidate"),body,2002,100)==CacheResult::PENDING,"failing candidate starts");drain(vc,&victims);
ck(vc.lastResult()==CacheResult::STORAGE_ERROR,"promotion failure reported");
for(const auto&file:victim_files)ck(victims.files.count(file.first)&&victims.files[file.first]==file.second,"promotion failure preserves victim");
// Cancellation closes the active writer, then removes at most one stage file per tick.
MemoryStorage cancelled;NomadNetCache cc(cancelled,cfg);drain(cc,&cancelled);
ck(cc.beginCommit(key(),std::vector<uint8_t>(200,'z'),3000,100)==CacheResult::PENDING,"cancel commit starts");
for(int i=0;i<5;++i)cc.service();
ck(cancelled.writing,"writer open before cancel");cc.cancel();ck(cc.busy(),"cancel cleanup deferred");
const auto cancel_ops=cancelled.operations;cc.service();ck(cancelled.operations==cancel_ops+1&&!cancelled.writing,"cancel closes one handle in one tick");drain(cc,&cancelled);
ck(cc.lastResult()==CacheResult::CANCELLED,"cancel completes without leaked handle");
// Cancellation retains handle ownership while close/abort is transiently busy.
MemoryStorage close_retry;NomadNetCache close_cache(close_retry,cfg);drain(close_cache,&close_retry);
ck(close_cache.beginCommit(key(),body,4000,100)==CacheResult::PENDING,"close retry seed");drain(close_cache,&close_retry);
ck(close_cache.beginLookup(key(),4001)==CacheResult::PENDING,"close retry lookup");close_cache.service();
close_retry.end_read_failures=1;close_cache.cancel();close_cache.service();
ck(close_cache.busy()&&!close_retry.active.empty(),"busy endRead retains read ownership");drain(close_cache,&close_retry);
ck(close_cache.lastResult()==CacheResult::CANCELLED&&close_retry.active.empty(),"endRead retry closes before cancellation completes");
MemoryStorage abort_retry;NomadNetCache abort_cache(abort_retry,cfg);drain(abort_cache,&abort_retry);
ck(abort_cache.beginCommit(key(),std::vector<uint8_t>(200,'q'),4100,100)==CacheResult::PENDING,"abort retry starts");
for(int i=0;i<5;++i){abort_cache.service();}abort_retry.abort_failures=1;abort_cache.cancel();abort_cache.service();
ck(abort_cache.busy()&&abort_retry.writing,"busy abort retains write ownership");drain(abort_cache,&abort_retry);
ck(abort_cache.lastResult()==CacheResult::CANCELLED&&!abort_retry.writing,"abort retry closes before cancellation completes");
// Commit admission itself performs no storage availability transaction; service owns cadence.
MemoryStorage cadence;NomadNetCache cadence_cache(cadence,cfg);drain(cadence_cache,&cadence);const auto admission_ops=cadence.operations;
ck(cadence_cache.beginCommit(key(),body,4200,100)==CacheResult::PENDING,"cadence commit admitted");
ck(cadence.operations==admission_ops,"beginCommit has no storage preflight operation");drain(cadence_cache,&cadence);
// Any short write aborts the active transaction before fsync or promotion.
MemoryStorage short_write;NomadNetCache short_cache(short_write,cfg);drain(short_cache,&short_write);short_write.max_write=2;
ck(short_cache.beginCommit(key(),body,4250,100)==CacheResult::PENDING,"short write candidate admitted");drain(short_cache,&short_write);
ck(short_cache.lastResult()==CacheResult::STORAGE_ERROR,"short write poisons cache transaction");
ck(short_write.abort_calls==1&&short_write.commit_calls==0,"short write aborts without fsync");
ck(short_write.files.empty(),"short write never promotes partial bytes");
// Invalidation retries transient unlink failures and cannot acknowledge while bytes remain.
MemoryStorage invalidate_retry;NomadNetCache invalidate_cache(invalidate_retry,cfg);drain(invalidate_cache,&invalidate_retry);
ck(invalidate_cache.beginCommit(key(),body,4300,100)==CacheResult::PENDING,"invalidate retry seed");drain(invalidate_cache,&invalidate_retry);
const auto invalidate_files=invalidate_retry.files;invalidate_retry.remove_result=StorageResult::BUSY;invalidate_retry.remove_failures=1;
ck(invalidate_cache.invalidate(key())==CacheResult::PENDING,"invalidate starts");invalidate_cache.service();
ck(invalidate_cache.busy()&&invalidate_retry.files==invalidate_files,"busy invalidation does not advance");drain(invalidate_cache,&invalidate_retry);
ck(invalidate_cache.lastResult()==CacheResult::MISS&&invalidate_retry.files.empty(),"invalidation succeeds only after all unlinks close");
// A permanent eviction unlink failure reports durable degradation and preserves the RAM victim.
MemoryStorage eviction_failure;CacheConfig one_cfg=cfg;one_cfg.max_entries=1;NomadNetCache eviction_cache(eviction_failure,one_cfg);drain(eviction_cache,&eviction_failure);
ck(eviction_cache.beginCommit(key("/page/victim"),body,4400,100)==CacheResult::PENDING,"eviction failure victim seed");drain(eviction_cache,&eviction_failure);
eviction_failure.remove_result=StorageResult::IO_ERROR;eviction_failure.remove_failures=100;eviction_failure.remove_fail_substring=eviction_cache.debugMetadataPath(key("/page/victim"),0).substr(0,55);
ck(eviction_cache.beginCommit(key("/page/new"),body,4401,100)==CacheResult::PENDING,"eviction failure candidate starts");drain(eviction_cache,&eviction_failure);
ck(eviction_cache.lastResult()==CacheResult::STORAGE_ERROR&&eviction_cache.entryCount()==2,"permanent eviction failure never reports stored or erases RAM victim");
// Recovery under tighter quotas reconciles expired/oldest entries before becoming complete.
MemoryStorage recovery_source;CacheConfig three_cfg=cfg;three_cfg.max_entries=3;NomadNetCache source_cache(recovery_source,three_cfg);drain(source_cache,&recovery_source);
for(int i=0;i<3;++i){auto k=key((std::string("/page/r")+char('a'+i)).c_str());ck(source_cache.beginCommit(k,body,4500+i,i==0?1:100)==CacheResult::PENDING,"recovery quota seed");drain(source_cache,&recovery_source);}
CacheConfig recovered_cfg=three_cfg;recovered_cfg.max_entries=2;NomadNetCache recovered_quota(recovery_source,recovered_cfg);drain(recovered_quota,&recovery_source);
ck(recovered_quota.recoveryComplete()&&recovered_quota.entryCount()<=2&&recovered_quota.totalBytes()<=recovered_cfg.max_bytes,"recovery cannot finish over entry or byte quota");
// An ambiguous/incomplete namespace scan is not authoritative for slot overwrite.
MemoryStorage uncertain_storage;NomadNetCache certain_cache(uncertain_storage,cfg);drain(certain_cache,&uncertain_storage);
ck(certain_cache.beginCommit(key(),body,4600,100)==CacheResult::PENDING,"uncertain seed");drain(certain_cache,&uncertain_storage);const auto certain_files=uncertain_storage.files;
uncertain_storage.next_list_fail_at=1;NomadNetCache uncertain_cache(uncertain_storage,cfg);drain(uncertain_cache,&uncertain_storage);
ck(uncertain_cache.beginCommit(key(),std::vector<uint8_t>{'x'},4601,100)==CacheResult::BYPASS,"incomplete recovery refuses unknown generation selection");
ck(uncertain_storage.files==certain_files,"incomplete recovery preserves last valid generation");
// A permanent per-record metadata read error makes every enumerated slot unknown.
MemoryStorage unread;NomadNetCache unread_source(unread,cfg);drain(unread_source,&unread);
ck(unread_source.beginCommit(key(),std::vector<uint8_t>{'a'},4650,100)==CacheResult::PENDING,"unread first slot seed");drain(unread_source,&unread);
ck(unread_source.beginCommit(key(),std::vector<uint8_t>{'b'},4651,100)==CacheResult::PENDING,"unread second slot seed");drain(unread_source,&unread);
const auto unread_snapshot=unread.files;unread.read_fail_path=unread_source.debugMetadataPath(key(),0);unread.read_fail_result=StorageResult::IO_ERROR;
NomadNetCache unread_reboot(unread,cfg);drain(unread_reboot,&unread);
ck(!unread_reboot.recoveryComplete(),"permanent metadata read error prevents authoritative recovery");
ck(unread_reboot.beginCommit(key(),std::vector<uint8_t>{'x'},4652,100)==CacheResult::BYPASS,"unread generation is never selected for replacement");
ck(unread.files==unread_snapshot,"metadata read error preserves exact unknown slot bytes");
// The same ownership rule applies when metadata is readable but body stat fails.
MemoryStorage unstat=unread;unstat.read_fail_path.clear();unstat.read_fail_result=StorageResult::OK;
unstat.stat_fail_path=unread_source.debugBodyPath(key(),1);unstat.stat_fail_result=StorageResult::IO_ERROR;const auto unstat_snapshot=unstat.files;
NomadNetCache unstat_reboot(unstat,cfg);drain(unstat_reboot,&unstat);
ck(!unstat_reboot.recoveryComplete(),"permanent body stat error prevents authoritative recovery");
ck(unstat_reboot.beginCommit(key(),std::vector<uint8_t>{'y'},4653,100)==CacheResult::BYPASS,"unstatted generation is never selected for replacement");
ck(unstat.files==unstat_snapshot,"body stat error preserves exact unknown slot bytes");
// Equal-sequence slots are accepted only when their canonical metadata bytes match exactly.
MemoryStorage conflict_storage;NomadNetCache conflict_cache(conflict_storage,cfg);drain(conflict_cache,&conflict_storage);
ck(conflict_cache.beginCommit(key(),body,4700,100)==CacheResult::PENDING,"conflict seed");drain(conflict_cache,&conflict_storage);
const auto g=conflict_cache.debugGeneration();const auto other_g=g^1U;
conflict_storage.files[conflict_cache.debugBodyPath(key(),other_g)]=conflict_storage.files[conflict_cache.debugBodyPath(key(),g)];
auto conflicting_meta=conflict_storage.files[conflict_cache.debugMetadataPath(key(),g)];
conflicting_meta[16]^=1U;const auto exact_hash=NomadNetCache::hash(conflicting_meta.data(),conflicting_meta.size()-8);
for(unsigned i=0;i<8;++i)conflicting_meta[conflicting_meta.size()-8+i]=static_cast<uint8_t>(exact_hash>>(8U*i));
conflict_storage.files[conflict_cache.debugMetadataPath(key(),other_g)]=conflicting_meta;
ck(conflict_cache.beginLookup(key(),4701)==CacheResult::PENDING,"equal sequence conflict lookup");drain(conflict_cache,&conflict_storage);
ck(conflict_cache.lastResult()==CacheResult::MISS,"equal sequence conflicting metadata rejected conservatively");
// Current-key-only recovery sheds the older slot rather than finishing over physical quota.
MemoryStorage current_only;NomadNetCache current_source(current_only,cfg);drain(current_source,&current_only);
ck(current_source.beginCommit(key(),std::vector<uint8_t>(20,'a'),4800,100)==CacheResult::PENDING,"current only first");drain(current_source,&current_only);
ck(current_source.beginCommit(key(),std::vector<uint8_t>(30,'b'),4801,100)==CacheResult::PENDING,"current only second");drain(current_source,&current_only);
const auto two_slot_bytes=current_source.totalBytes();CacheConfig current_tight=cfg;current_tight.max_bytes=two_slot_bytes-1;
NomadNetCache current_reboot(current_only,current_tight);drain(current_reboot,&current_only);
ck(current_reboot.recoveryComplete()&&current_reboot.totalBytes()<=current_tight.max_bytes,"current-key fallback physical bytes reconciled");
ck(current_reboot.beginLookup(key(),4802)==CacheResult::PENDING,"current-key post-reconcile lookup");drain(current_reboot,&current_only);ck(current_reboot.lastResult()==CacheResult::HIT,"current-key reconciliation preserves newest data");
std::cout<<(f?"failed":"passed")<<"\n";return f?1:0;}
+5
View File
@@ -0,0 +1,5 @@
from pathlib import Path
from native_test import compile_and_run
ROOT=Path(__file__).resolve().parents[2]; INC=ROOT/"lib/tdeck_ui/UI/LXMF"
def test_nomadnet_cache_fault_matrix(tmp_path):
compile_and_run(tmp_path,name="nomadnet_cache",sources=[ROOT/"tests/native/test_nomadnet_cache.cpp",INC/"NomadNetCache.cpp"],include_dirs=[INC],sanitize=True)
+22
View File
@@ -0,0 +1,22 @@
#include <iostream>
#include <map>
#include <vector>
#include <cstring>
#include "NomadNetCacheFlow.h"
using namespace UI::LXMF::NomadNet;
struct Mem:NomadNetStorage{std::map<std::string,std::vector<uint8_t>>f;std::string a;size_t p=0;bool w=false;bool isAvailable()const override{return true;}StorageResult beginRead(const char*n,uint32_t&s)override{auto i=f.find(n);if(i==f.end())return StorageResult::MISS;a=n;p=0;s=i->second.size();return StorageResult::OK;}StorageResult readChunk(uint8_t*o,size_t c,size_t&n)override{auto&v=f[a];n=std::min(c,v.size()-p);memcpy(o,v.data()+p,n);p+=n;return StorageResult::OK;}StorageResult endRead()override{return StorageResult::OK;}StorageResult beginWrite(const char*n)override{a=n;f[a].clear();w=true;return StorageResult::OK;}StorageResult writeChunk(const uint8_t*d,size_t z,size_t&n)override{n=z;f[a].insert(f[a].end(),d,d+z);return StorageResult::OK;}StorageResult commitWrite()override{w=false;return StorageResult::OK;}StorageResult abortWrite()override{w=false;return StorageResult::OK;}StorageResult remove(const char*n)override{return f.erase(n)?StorageResult::OK:StorageResult::MISS;}StorageResult rename(const char*x,const char*y)override{auto i=f.find(x);if(i==f.end())return StorageResult::MISS;f[y]=i->second;f.erase(i);return StorageResult::OK;}StorageResult stat(const char*,uint32_t&)override{return StorageResult::MISS;}StorageResult beginList(const char*)override{return StorageResult::OK;}StorageResult nextList(char*,size_t,bool&d)override{d=true;return StorageResult::OK;}StorageResult endList()override{return StorageResult::OK;}};
int main(){int f=0;auto ck=[&](bool x,const char*n){if(!x){f++;std::cerr<<"FAIL "<<n<<"\n";}};Mem s;NomadNetCache c(s);NomadNetCacheFlow flow(c);CacheKey k{"0123456789abcdef0123456789abcdef","/page/index.mu",RequestDataClass::NIL};
ck(flow.begin(k,100,false)==CacheFlowState::LOOKUP,"lookup first");for(int i=0;i<10&&flow.state()==CacheFlowState::LOOKUP;i++)flow.service();ck(flow.state()==CacheFlowState::NEED_LIVE,"miss needs live");std::vector<uint8_t>b={'o','k'};CacheEligibility e{true,true,false,false,false,false,RequestDataClass::NIL};ck(flow.acceptLive(b,e,100),"valid live accepted");ck(flow.pageReady()&&flow.status()=="Page loaded (live)","render ready before commit");for(int i=0;i<20;i++)flow.service();
NomadNetCacheFlow hit(c);ck(hit.begin(k,101,false)==CacheFlowState::LOOKUP,"second lookup");for(int i=0;i<10&&hit.state()==CacheFlowState::LOOKUP;i++)hit.service();ExternalVector<uint8_t>out;ck(hit.state()==CacheFlowState::READY&&hit.takePage(out)&&std::equal(out.begin(),out.end(),b.begin(),b.end())&&hit.status()=="Cached page; current reachability not checked","hit without peer and without internal-vector copy");
NomadNetCacheFlow fields(c);k.request_data=RequestDataClass::FIELDS;fields.begin(k,101,false);fields.service();ck(fields.state()==CacheFlowState::NEED_LIVE,"request data bypass");
k.request_data=RequestDataClass::NIL;NomadNetCacheFlow reload(c);reload.begin(k,101,true);while(reload.state()==CacheFlowState::INVALIDATE)reload.service();ck(reload.state()==CacheFlowState::NEED_LIVE,"reload bypass invalidates without history-side effects");
NomadNetCacheFlow malformed(c);malformed.begin(k,101,false);while(malformed.state()==CacheFlowState::LOOKUP)malformed.service();CacheEligibility bad{true,false,false,true,false,false,RequestDataClass::NIL};ck(!malformed.acceptLive(b,bad,101)&&malformed.state()==CacheFlowState::FAILED,"malformed not committed");
NomadNetCacheFlow cancelled(c);cancelled.begin(k,101,false);cancelled.cancel();cancelled.service();ck(cancelled.state()==CacheFlowState::CANCELLED&&!cancelled.pageReady(),"navigation cancels lookup");
// Reload during startup recovery remains in explicit invalidation until the old generation is physically gone.
NomadNetCache recovering(s);NomadNetCacheFlow recovering_reload(recovering);k.request_data=RequestDataClass::NIL;
ck(recovering_reload.begin(k,102,true)==CacheFlowState::INVALIDATE,"reload during recovery waits for invalidation admission");
for(int i=0;i<200&&recovering_reload.state()==CacheFlowState::INVALIDATE;++i)recovering_reload.service();
ck(recovering_reload.state()==CacheFlowState::NEED_LIVE,"reload starts exactly one live fetch only after terminal invalidation");
ck(recovering.beginLookup(k,102)==CacheResult::PENDING,"post reload invalidation lookup");for(int i=0;i<50&&recovering.busy();++i)recovering.service();
ck(recovering.lastResult()!=CacheResult::HIT,"reload during recovery removed stale generation");
std::cout<<(f?"failed":"passed")<<"\n";return f?1:0;}
+5
View File
@@ -0,0 +1,5 @@
from pathlib import Path
from native_test import compile_and_run
ROOT=Path(__file__).resolve().parents[2];INC=ROOT/"lib/tdeck_ui/UI/LXMF"
def test_incremental_cache_owner_flow(tmp_path):
compile_and_run(tmp_path,name="cache_flow",sources=[ROOT/"tests/native/test_nomadnet_cache_flow.cpp",INC/"NomadNetCache.cpp",INC/"NomadNetCacheFlow.cpp"],include_dirs=[INC],sanitize=True)
@@ -0,0 +1,53 @@
#include <cstdlib>
#include <cstdint>
#include <iostream>
#include <new>
#include <vector>
#include "NomadNetMailbox.h"
using UI::LXMF::NomadNet::AsyncMailbox;
namespace {
bool fail_allocations = false;
}
void* operator new(std::size_t size) {
if (fail_allocations) throw std::bad_alloc();
if (void* value = std::malloc(size)) return value;
throw std::bad_alloc();
}
void operator delete(void* value) noexcept { std::free(value); }
void operator delete(void* value, std::size_t) noexcept { std::free(value); }
int main() {
AsyncMailbox mailbox;
const std::vector<std::uint8_t> link{1};
const std::vector<std::uint8_t> request{2};
const std::vector<std::uint8_t> response(4096, 0x5a);
mailbox.begin(link, 77);
mailbox.expect_request(request);
bool accepted = false;
bool escaped = false;
fail_allocations = true;
try {
accepted = mailbox.publish_response(
request, response.data(), response.size(), response.size());
} catch (const std::bad_alloc&) {
escaped = true;
}
fail_allocations = false;
AsyncMailbox::Event event;
const bool bounded_failure = mailbox.take(event) &&
event.kind == AsyncMailbox::Kind::FAILED && event.data.empty() &&
event.transfer_size == 0 && event.generation == 77;
if (escaped || !accepted || !bounded_failure) {
std::cerr << "escaped=" << escaped << " accepted=" << accepted
<< " bounded_failure=" << bounded_failure << "\n";
return 1;
}
std::cout << "passed\n";
return 0;
}
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
from native_test import compile_and_run
ROOT = Path(__file__).resolve().parents[2]
INC = ROOT / "lib/tdeck_ui/UI/LXMF"
def test_nomadnet_mailbox_contains_allocator_failure(tmp_path):
compile_and_run(
tmp_path,
name="nomadnet_mailbox_oom",
sources=[ROOT / "tests/native/test_nomadnet_mailbox_oom.cpp"],
include_dirs=[INC],
sanitize=True,
)
+44
View File
@@ -0,0 +1,44 @@
#include <cstdint>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "NomadNetStorage.h"
using namespace UI::LXMF::NomadNet;
struct ScriptedStorage final : NomadNetStorage {
bool available=true, busy=false, full=false, open=false, aborted=false, committed=false, poisoned=false;
std::size_t max_write=SIZE_MAX; bool zero_progress=false;
std::vector<uint8_t> bytes;
bool isAvailable() const override { return available; }
StorageResult beginRead(const char*, uint32_t& size) override { if(!available)return StorageResult::UNAVAILABLE; size=bytes.size();open=true;return StorageResult::OK; }
StorageResult readChunk(uint8_t* out,std::size_t cap,std::size_t& count) override { if(busy)return StorageResult::BUSY; count=std::min(cap,bytes.size());std::memcpy(out,bytes.data(),count);return StorageResult::OK; }
StorageResult endRead() override {open=false;return StorageResult::OK;}
StorageResult beginWrite(const char*) override { if(!available)return StorageResult::UNAVAILABLE;if(busy)return StorageResult::BUSY;if(full)return StorageResult::FULL;open=true;poisoned=false;bytes.clear();return StorageResult::OK; }
StorageResult writeChunk(const uint8_t* data,std::size_t size,std::size_t& written) override { if(busy){written=0;poisoned=true;return StorageResult::BUSY;}if(full){written=0;poisoned=true;return StorageResult::FULL;}if(zero_progress){written=0;poisoned=true;return StorageResult::NO_PROGRESS;}written=std::min(size,max_write);bytes.insert(bytes.end(),data,data+written);if(written!=size)poisoned=true;return written==size?StorageResult::OK:StorageResult::PARTIAL_WRITE; }
StorageResult commitWrite() override { if(!open||poisoned)return StorageResult::INVALID_STATE;open=false;committed=true;return StorageResult::OK; }
StorageResult abortWrite() override {open=false;poisoned=false;aborted=true;return StorageResult::OK;}
StorageResult remove(const char*) override{return StorageResult::OK;}
StorageResult rename(const char*,const char*) override{return StorageResult::OK;}
StorageResult stat(const char*,uint32_t&) override{return StorageResult::MISS;}
StorageResult beginList(const char*) override{return StorageResult::OK;}
StorageResult nextList(char*,std::size_t,bool& done) override{done=true;return StorageResult::OK;}
StorageResult endList() override{return StorageResult::OK;}
};
int main(){int failed=0;auto check=[&](bool c,const char*n){if(!c){++failed;std::cerr<<"FAIL: "<<n<<"\n";}};
check(storage_result_is_transient(StorageResult::BUSY),"busy typed transient");
check(storage_result_is_unavailable(StorageResult::UNAVAILABLE),"unavailable typed");
check(storage_result_is_write_failure(StorageResult::FULL),"full typed write failure");
ScriptedStorage s; const uint8_t data[]={1,2,3,4};std::size_t n=0;
check(s.beginWrite("/cache/x")==StorageResult::OK,"begin write");
s.max_write=2;check(s.writeChunk(data,4,n)==StorageResult::PARTIAL_WRITE&&n==2,"partial write observable");
check(s.commitWrite()==StorageResult::INVALID_STATE&&!s.committed,"partial write poisons descriptor");
check(s.abortWrite()==StorageResult::OK&&s.aborted&&!s.committed,"partial write aborts");
s.zero_progress=true;check(s.beginWrite("/cache/y")==StorageResult::OK&&s.writeChunk(data,4,n)==StorageResult::NO_PROGRESS&&n==0,"zero progress typed");
check(s.abortWrite()==StorageResult::OK,"zero progress abort");
s.available=false;uint32_t size=0;check(s.beginRead("x",size)==StorageResult::UNAVAILABLE,"missing card falls through");
s.available=true;s.busy=true;check(s.beginWrite("x")==StorageResult::BUSY,"busy card typed");
s.busy=false;s.full=true;check(s.beginWrite("x")==StorageResult::FULL,"full card typed");
std::cout<<(failed?"failed":"passed")<<"\n";return failed?1:0;}
+5
View File
@@ -0,0 +1,5 @@
from pathlib import Path
from native_test import compile_and_run
ROOT=Path(__file__).resolve().parents[2]
def test_nomadnet_storage_contract(tmp_path):
compile_and_run(tmp_path,name="nomadnet_storage",sources=[ROOT/"tests/native/test_nomadnet_storage.cpp"],include_dirs=[ROOT/"lib/tdeck_ui/UI/LXMF"])