mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-09-27 01:48:00 +00:00
feat: add bounded NomadNet SD page cache
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,¤t_only);
|
||||
ck(current_source.beginCommit(key(),std::vector<uint8_t>(20,'a'),4800,100)==CacheResult::PENDING,"current only first");drain(current_source,¤t_only);
|
||||
ck(current_source.beginCommit(key(),std::vector<uint8_t>(30,'b'),4801,100)==CacheResult::PENDING,"current only second");drain(current_source,¤t_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,¤t_only);
|
||||
ck(current_reboot.recoveryComplete()&¤t_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,¤t_only);ck(current_reboot.lastResult()==CacheResult::HIT,"current-key reconciliation preserves newest data");
|
||||
|
||||
std::cout<<(f?"failed":"passed")<<"\n";return f?1:0;}
|
||||
@@ -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)
|
||||
@@ -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;}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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;}
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user