Merge pull request #95 from torlando-tech/perf/conversation-list-load
Build and Deploy Firmware / build-and-deploy (push) Canceled after 0s
Test / Pyxis pytest suite (build_scripts + native) (push) Canceled after 0s
Test / microReticulum native unit tests (PlatformIO native17) (push) Canceled after 0s

perf(messages): fast conversation-list load + real unread badges
This commit is contained in:
Torlando authored and GitHub committed 2026-09-03 23:00:32 -04:00
commit 8c0d38ee2e
11 files changed
+978 -68

No files matched your search

+3
View File
@@ -12,3 +12,6 @@ docs/flasher/firmware/
# is newer (see should_regenerate), so contributors with the deps
# installed get fresh rebuilds while contributors without them still
# get a working binary from the committed copy.
# IDE-generated build database
compile_commands.json
+64 -21
View File
@@ -171,6 +171,15 @@ void ChatScreen::load_conversation(const Bytes& peer_hash, ::LXMF::MessageStore&
_peer_hash = peer_hash;
_message_store = &store;
// A peer change cancels any in-flight background fill from the
// previous conversation (it would otherwise prepend the previous
// conversation's older rows into the new one). refresh() re-arms the
// fill for the new peer.
if (_bg_fill_active.exchange(false)) {
_keep_bottom_during_background_fill.store(false);
_bg_fill_target = _display_start_idx; // fill is a no-op now
}
{
char log_buf[64];
snprintf(log_buf, sizeof(log_buf), "Loading conversation with peer %.8s...",
@@ -251,9 +260,9 @@ void ChatScreen::refresh() {
for (size_t i = _display_start_idx; i < _all_message_hashes.size(); i++) {
const auto& msg_hash = _all_message_hashes[i];
// Use fast metadata loader (no msgpack unpacking)
// Use fast metadata loader (cache hit: O(1) in-memory; miss: one
// LittleFS read that warms the cache for every later touch).
::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(msg_hash);
if (!meta.valid) {
continue;
}
@@ -335,6 +344,8 @@ void ChatScreen::load_more_messages(size_t batch) {
--i; // Decrement first since we're iterating backwards
const auto& msg_hash = _all_message_hashes[i];
// Use fast metadata loader (cache hit: O(1) in-memory; miss: one
// LittleFS read that warms the cache for every later touch).
::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(msg_hash);
if (!meta.valid) {
continue;
@@ -735,34 +746,66 @@ void ChatScreen::on_message_long_pressed(lv_event_t* event) {
lv_obj_t* bubble = lv_event_get_target(event);
lv_obj_t* row = lv_obj_get_parent(bubble);
// Bubbles render a truncated copy of long messages, so recover the FULL
// content for this row (reverse-lookup row -> hash -> stored item) for both
// the detail view and Copy.
String full;
// Bubbles render a display-capped copy (metadata cache / MAX_DISPLAY_
// CHARS), so the full text must be recovered from the store. This
// handler runs on the LVGL task and the store is main-loop-only, so
// record the request and let tick_pending_full_message() (main loop)
// do the disk read + modal build.
RNS::Bytes hash;
for (const auto& kv : screen->_message_rows) {
if (kv.second == row) {
for (const auto& m : screen->_messages) {
if (m.message_hash == kv.first) {
full = m.content;
break;
}
}
hash = kv.first;
break;
}
}
if (full.length() == 0) { // fallback to the (possibly truncated) label text
lv_obj_t* label = lv_obj_get_child(bubble, 0);
if (label) {
const char* t = lv_label_get_text(label);
if (t) full = t;
}
if (hash.size() == 0) {
return;
}
if (screen->_pending_full_message.load()) {
// A request is already queued; the next modal supersedes this one.
return;
}
// Runs while the LVGL task holds the mutex (lvgl_task wraps
// lv_task_handler in it), so these writes serialize with the main
// loop's copy of _pending_full_message_hash under LVGL_LOCK.
screen->_pending_full_message_hash = hash;
screen->_pending_copy_text = "";
screen->_pending_full_message.store(true);
}
// Complete a pending long-press full-message request (see
// on_message_long_pressed). Runs on the main loop: the disk-bound
// load_message_content() happens OUTSIDE the LVGL lock (same task that
// does all other store I/O), then the modal is built under LVGL_LOCK().
// This path is user-triggered (once per long press), not per frame.
void ChatScreen::tick_pending_full_message() {
if (!_pending_full_message.load()) {
return;
}
// Copy the hash under the LVGL lock: the LVGL task writes it while
// holding that same lock (see on_message_long_pressed), so this is a
// safe handoff. Then the disk read happens OUTSIDE the lock.
RNS::Bytes hash;
{
LVGL_LOCK();
if (!_pending_full_message.load()) {
return;
}
hash = _pending_full_message_hash;
// Clear before the read: a superseding long-press can re-arm while
// we are here and must not be lost.
_pending_full_message.store(false);
}
if (!_message_store) {
return;
}
String full = String(_message_store->load_message_content(hash).c_str());
if (full.length() == 0) {
return;
}
screen->_pending_copy_text = full;
screen->show_full_message(full);
LVGL_LOCK();
_pending_copy_text = full;
show_full_message(full);
}
// Full-screen scrollable view of a single message's complete text, with Copy.
+23
View File
@@ -108,6 +108,21 @@ public:
*/
void tick_background_fill();
/**
* Complete a pending long-press full-message request. Call from
* UIManager::update() on the main loop after the chat screen is shown.
*
* The long-press handler (LVGL task) only records which message was
* pressed; the actual full content read
* (MessageStore::load_message_content — one bounded JSON read, no
* msgpack unpack) and the modal build happen here, off the LVGL task.
* The store is main-loop-only (it shares one JsonDocument between
* save + load), and the rendered rows only hold the display-capped
* content (the metadata cache caps at 600 chars), so the full view
* must come from disk.
*/
void tick_pending_full_message();
/**
* Set callback for back button
* @param callback Function to call when back button is pressed
@@ -217,6 +232,14 @@ private:
// user-triggered pagination at the top must preserve the user's position.
std::atomic<bool> _keep_bottom_during_background_fill{false};
// Long-press full-message view, deferred to the main loop. The LVGL
// event handler only records the hash; tick_pending_full_message()
// does the (disk-bound) load_message_content() off the LVGL task and
// builds the modal. Same set-before-arm ordering as the background
// fill above.
std::atomic<bool> _pending_full_message{false};
RNS::Bytes _pending_full_message_hash;
// Load more messages (infinite scroll + background fill)
void load_more_messages(size_t batch = MESSAGES_PER_PAGE);
void scroll_to_bottom();
+413 -40
View File
@@ -35,6 +35,17 @@ ConversationListScreen::ConversationListScreen(lv_obj_t* parent)
_label_battery_icon(nullptr), _label_battery_pct(nullptr),
_lora_interface(nullptr), _ble_interface(nullptr), _gps(nullptr),
_message_store(nullptr) {
// Queue mutex guards the deferred queues shared between the LVGL
// task (producers) and the main loop (flush_* consumers) — create
// before any task can touch them. Fail closed: if allocation
// fails, every guarded site below refuses queue mutation and the
// screen degrades (no mark-read/drop/deferred name writes) rather
// than running the queues unsynchronized.
_queue_mutex = xSemaphoreCreateMutex();
if (!_queue_mutex) {
ERROR("ConversationListScreen: queue mutex allocation failed; "
"deferred mark-read/drop/name-write queues disabled (fail closed)");
}
LVGL_LOCK();
// Create screen object
@@ -66,8 +77,37 @@ ConversationListScreen::~ConversationListScreen() {
if (_screen) {
lv_obj_del(_screen);
}
// No task outlives the screen: safe to delete outside the lock.
if (_queue_mutex) {
vSemaphoreDelete(_queue_mutex);
_queue_mutex = nullptr;
}
}
namespace {
// RAII guard for _queue_mutex. The guarded sections are bounded vector
// operations (no store I/O, no LVGL lock), so a missed acquisition is a
// logic error — treat it like an assert by failing closed.
class QueueGuard {
public:
explicit QueueGuard(SemaphoreHandle_t m) : _m(m) {
if (_m) {
xSemaphoreTake(_m, portMAX_DELAY);
}
}
~QueueGuard() {
if (_m) {
xSemaphoreGive(_m);
}
}
QueueGuard(const QueueGuard&) = delete;
QueueGuard& operator=(const QueueGuard&) = delete;
private:
SemaphoreHandle_t _m;
};
} // namespace
void ConversationListScreen::create_header() {
_header = lv_obj_create(_screen);
lv_obj_set_size(_header, LV_PCT(100), 36);
@@ -198,39 +238,128 @@ void ConversationListScreen::refresh() {
return;
}
INFO("Refreshing conversation list");
// Revalidate-then-render: gather the row data first (now O(1) per
// conversation — index preview + hash + unread, no message-file I/O)
// and compare it against what is already on screen. The old path
// unconditionally ran lv_obj_clean(_list) + recreated 5-7 LVGL objects
// per row on EVERY refresh (navigation back to Messages, every
// 750ms coalesced inbound batch, name-resolution sweep) — that
// widget churn was what still made Messages feel slow vs NomadNet /
// Network / Maps, which build once and just unhide.
std::vector<ConversationItem> gathered;
// Clear existing items (also removes from focus group when deleted)
lv_obj_clean(_list);
_conversations.clear();
_conversation_containers.clear();
_peer_hash_pool.clear();
_has_unresolved_names = false;
// Load conversations from store
std::vector<Bytes> peer_hashes = _message_store->get_conversations();
// Reserve capacity to avoid reallocations during population
_peer_hash_pool.reserve(peer_hashes.size());
_conversations.reserve(peer_hashes.size());
_conversation_containers.reserve(peer_hashes.size());
gathered.reserve(peer_hashes.size());
{
char log_buf[48];
snprintf(log_buf, sizeof(log_buf), " Found %zu conversations", peer_hashes.size());
INFO(log_buf);
// DEBUG, not INFO: refresh() now runs on every navigation and
// periodic sweep, and serial output happens under the LVGL lock
// (the render task waits on the same lock the serial flush holds).
DEBUG(log_buf);
}
for (const auto& peer_hash : peer_hashes) {
std::vector<Bytes> messages = _message_store->get_messages_for_conversation(peer_hash);
if (messages.empty()) {
// Last-message hash comes from the conversation index cache
// (O(1) in-memory lookup, no 8KB hash-array copy and no I/O).
Bytes last_msg_hash = _message_store->get_last_message_hash(peer_hash);
if (!last_msg_hash) {
continue;
}
// Load last message for preview
Bytes last_msg_hash = messages.back();
::LXMF::LXMessage last_msg = _message_store->load_message(last_msg_hash);
// Preview + timestamp come from the conversation index cache when
// available: O(1), zero I/O. Only fall back to load_message_metadata()
// when the cache is empty — the first refresh after a firmware
// upgrade (index generation predates the field) or after a
// corrupt-message drop. The write-through below re-pops the cache,
// so the fallback happens at most once per conversation per
// firmware generation.
//
// The metadata path does one open + one filtered parse per
// conversation (the old unconditional path opened + parsed the
// newest message file on EVERY refresh, which dominated list-load
// time on SPI LittleFS).
std::string preview;
double preview_ts = 0.0;
bool have_preview =
_message_store->get_last_message_preview(peer_hash, preview, preview_ts);
bool need_metadata = !have_preview;
bool queued_drops = false;
::LXMF::MessageStore::MessageMetadata last_meta;
last_meta.valid = false;
if (need_metadata) {
last_meta = _message_store->load_message_metadata(last_msg_hash);
}
if (need_metadata && !last_meta.valid) {
// The newest message is corrupt/unreadable. Don't hide the
// conversation over one bad message: drop the unreadable
// message(s) and preview the newest readable one instead.
//
// This only costs the get_messages_for_conversation() array
// copy and the extra metadata reads on the corrupt path — the
// common (cached) path above stays O(1) + no I/O.
//
// delete_message() commits the index and removes files, so it
// must not run under this LVGL lock: we only queue the hashes
// here, and UIManager::update() drains them (before taking the
// lock) — same deferred pattern as mark-read / name writes.
// The store's index updates last_message_hash on delete, so
// the next refresh converges to the same preview.
std::vector<Bytes> hashes =
_message_store->get_messages_for_conversation(peer_hash);
request_drop_message(last_msg_hash); // tail is the known-bad one
queued_drops = true;
// Walk newest→oldest over all but the tail (array is
// chronological; hashes.back() is the tail we just queued) to
// find the newest readable preview, queuing any unreadable
// messages along the way.
for (int i = (int)hashes.size() - 2; i >= 0; --i) {
::LXMF::MessageStore::MessageMetadata m =
_message_store->load_message_metadata(hashes[i]);
if (m.valid) {
last_meta = m; // newest readable preview
break;
}
request_drop_message(hashes[i]);
queued_drops = true;
}
if (!last_meta.valid) {
// Every message was unreadable; all queued for drop. Skip
// the row this refresh — the store converges to none.
continue;
}
}
// Re-pop the preview cache from the metadata we just read (the
// next refresh becomes O(1) with no I/O). Only meaningful on the
// fallback path — when the cache was already serving us there is
// nothing new to write. An EMPTY content re-pops as a valid
// cached empty preview (the store marks it populated), so
// empty-content tails — location shares, blank pings — also stop
// falling back. The one-shot index commit below persists the
// repop so a reboot does not re-pay every fallback. Skipped
// when drops were queued: the drained deletes update
// last_message_hash, so a preview written for the old tail would
// be stale for one refresh — let the next fallback re-read the
// converged tail instead.
if (need_metadata && last_meta.valid && !queued_drops) {
_message_store->set_last_message_preview(peer_hash,
last_meta.content.substr(0, 47));
// refresh() runs on the LVGL task; the main-loop drain reads
// the flag — set under _queue_mutex (brief, no I/O inside).
// Fail closed: without the mutex the deferred commit cannot
// run safely and would leave the preview uncommitted anyway.
if (_queue_mutex) {
QueueGuard guard(_queue_mutex);
_index_commit_pending = true;
}
}
// Create conversation item
ConversationItem item;
@@ -254,8 +383,14 @@ void ConversationListScreen::refresh() {
// (held by UIManager::update()). UIManager::update() flushes
// these before it takes the lock so the I/O never stalls the
// render task. (Same rationale as on_message_received.)
_pending_name_writes.emplace_back(
peer_hash, std::string(display_name.c_str()));
// Appended from the LVGL task; the main-loop drain swaps
// the queue — take _queue_mutex (brief, no I/O inside).
// Fail closed (see constructor) if the mutex is unavailable.
if (_queue_mutex) {
QueueGuard guard(_queue_mutex);
_pending_name_writes.emplace_back(
peer_hash, std::string(display_name.c_str()));
}
}
}
if (!resolved && _message_store) {
@@ -270,17 +405,69 @@ void ConversationListScreen::refresh() {
_has_unresolved_names = true;
}
// Get message content for preview
String content((const char*)last_msg.content().data(), last_msg.content().size());
// Get message content for preview (index cache, or pre-extracted
// metadata on the fallback path — no msgpack unpacking)
String content(have_preview ? preview.c_str() : last_meta.content.c_str());
item.last_message = content.substring(0, 30); // Truncate to 30 chars
if (content.length() > 30) {
item.last_message += "...";
}
item.timestamp = (uint32_t)last_msg.timestamp();
item.timestamp = (uint32_t)(have_preview ? preview_ts : last_meta.timestamp);
item.timestamp_str = format_timestamp(item.timestamp);
item.unread_count = 0; // TODO: Track unread count
// Unread count straight from the conversation index (persisted,
// incremented on incoming save, cleared when the user opens the
// chat via request_mark_read()).
item.unread_count =
(uint16_t)_message_store->get_conversation_unread_count(peer_hash);
gathered.push_back(item);
}
// Revalidate: skip the LVGL rebuild entirely when nothing changed.
// Rows keep their focus-group membership and the screen keeps its
// scroll position, and the render task costs only the O(1) gather
// above. This is the common case: navigation back to Messages with
// no new traffic, 750ms coalesced refreshes on an idle link, and
// the update_status() name-resolution sweep.
//
// (Badge edge case: a clicked row has its badge widget deleted and a
// mark-read queued; the mark-read flush runs in UIManager::update()
// BEFORE any later refresh takes the lock, so a refresh in that
// window sees the data unchanged (badge stays off) and the first
// refresh after the flush sees unread==0 and rebuilds correctly.)
bool changed = (gathered.size() != _conversations.size());
if (!changed) {
for (size_t i = 0; i < gathered.size(); ++i) {
const ConversationItem& g = gathered[i];
const ConversationItem& c = _conversations[i];
if (g.peer_hash != c.peer_hash ||
g.peer_name != c.peer_name ||
g.last_message != c.last_message ||
g.timestamp != c.timestamp ||
g.unread_count != c.unread_count) {
changed = true;
break;
}
}
}
if (!changed) {
return; // rows on screen are current
}
// Data changed: rebuild the list (full rebuild — a partial per-row
// diff is not worth the extra bookkeeping for a list this size).
lv_obj_clean(_list);
_conversations.clear();
_conversation_containers.clear();
_badge_pool.clear();
_peer_hash_pool.clear();
_peer_hash_pool.reserve(gathered.size());
_conversations.reserve(gathered.size());
_conversation_containers.reserve(gathered.size());
_badge_pool.reserve(gathered.size());
for (const ConversationItem& item : gathered) {
_conversations.push_back(item);
create_conversation_item(item);
}
@@ -301,14 +488,27 @@ void ConversationListScreen::flush_pending_name_writes() {
// Called from UIManager::update() BEFORE it takes the LVGL lock.
// set_display_name() hits microStore/LittleFS; running it inside refresh()
// (under the lock) serially stalls the LVGL render task on a cold-boot
// announce burst. Same fix as UIManager::on_message_received.
if (!_message_store || _pending_name_writes.empty()) {
// announce burst. Same fix as UIManager::on_message_received. Swap the
// queue under _queue_mutex (refresh() appends from the LVGL task), then
// do the I/O outside it. Fail closed (no draining) if the mutex is
// unavailable.
std::vector<std::pair<Bytes, std::string>> batch;
{
if (!_queue_mutex) {
return;
}
QueueGuard guard(_queue_mutex);
if (_pending_name_writes.empty()) {
return;
}
batch.swap(_pending_name_writes);
}
if (!_message_store) {
return;
}
for (const auto& w : _pending_name_writes) {
for (const auto& w : batch) {
_message_store->set_display_name(w.first, w.second);
}
_pending_name_writes.clear();
}
void ConversationListScreen::create_conversation_item(const ConversationItem& item) {
@@ -356,12 +556,17 @@ void ConversationListScreen::create_conversation_item(const ConversationItem& it
lv_obj_t* label_time = lv_label_create(container);
lv_label_set_text(label_time, item.timestamp_str.c_str());
lv_obj_align(label_time, LV_ALIGN_BOTTOM_RIGHT, -6, -4);
// Leave room for the unread badge (20px + 6px pad) when one is
// present; the badge occupies the bottom-right corner.
lv_obj_align(label_time, LV_ALIGN_BOTTOM_RIGHT,
item.unread_count > 0 ? -30 : -6, -4);
lv_obj_set_style_text_color(label_time, Theme::textMuted(), 0);
// Unread count badge
// Unread count badge. Always append to _badge_pool (nullptr when no
// badge) so the pool stays index-aligned with _conversation_containers.
lv_obj_t* badge = nullptr;
if (item.unread_count > 0) {
lv_obj_t* badge = lv_obj_create(container);
badge = lv_obj_create(container);
lv_obj_set_size(badge, 20, 20);
lv_obj_align(badge, LV_ALIGN_BOTTOM_RIGHT, -6, -4);
lv_obj_set_style_bg_color(badge, Theme::error(), 0);
@@ -370,20 +575,183 @@ void ConversationListScreen::create_conversation_item(const ConversationItem& it
lv_obj_set_style_pad_all(badge, 0, 0);
lv_obj_t* label_count = lv_label_create(badge);
lv_label_set_text_fmt(label_count, "%d", item.unread_count);
// Badge is a 20px circle; cap the digit count so it always fits.
int shown = item.unread_count;
if (shown > 9) shown = 9;
lv_label_set_text_fmt(label_count, "%d", shown);
lv_obj_center(label_count);
lv_obj_set_style_text_color(label_count, lv_color_white(), 0);
}
_badge_pool.push_back(badge);
}
void ConversationListScreen::update_unread_count(const Bytes& peer_hash, uint16_t unread_count) {
LVGL_LOCK();
// Find conversation and update
for (auto& conv : _conversations) {
if (conv.peer_hash == peer_hash) {
conv.unread_count = unread_count;
refresh(); // Redraw list
break;
void ConversationListScreen::request_mark_read(const Bytes& peer_hash) {
// Defers the actual store mutation. mark_conversation_read() commits
// the index to LittleFS, so it runs from UIManager::update() BEFORE
// the LVGL lock (same pattern as flush_pending_name_writes) — not
// from this LVGL event callback. The queue itself is shared with the
// main-loop drain, so take _queue_mutex (brief, no I/O inside). Fail
// closed (no enqueue) if the mutex is unavailable.
if (!_queue_mutex) {
return;
}
{
QueueGuard guard(_queue_mutex);
for (const auto& h : _pending_mark_reads) {
if (h == peer_hash) return;
}
_pending_mark_reads.push_back(peer_hash);
}
}
void ConversationListScreen::flush_pending_mark_reads() {
// Called from UIManager::update() BEFORE it takes the LVGL lock.
// mark_conversation_read() hits microStore/LittleFS; the LVGL event
// that requested the mark-read only set the flag. Swap the queue
// under _queue_mutex, then do the I/O outside it (a new request
// arriving during the I/O waits its one drain tick). Fail closed
// (no draining) if the mutex is unavailable.
std::vector<Bytes> batch;
{
if (!_queue_mutex) {
return;
}
QueueGuard guard(_queue_mutex);
if (_pending_mark_reads.empty()) {
return;
}
batch.swap(_pending_mark_reads);
}
if (!_message_store) {
return;
}
for (const auto& h : batch) {
_message_store->mark_conversation_read(h);
}
}
void ConversationListScreen::request_drop_message(const Bytes& message_hash) {
// Queues a corrupt/unreadable message for deletion. The actual
// delete_message() (index commit + payload file removal, and the
// last_message_hash update) runs from flush_pending_drops() OUTSIDE
// the LVGL lock. A bounded number of unreadable messages can be
// queued per refresh (one per conversation, walking the tail), so the
// queue cannot grow without bound. Shared with the main-loop drain,
// so take _queue_mutex (brief, no I/O inside). Fail closed (no
// enqueue) if the mutex is unavailable.
if (!_queue_mutex) {
return;
}
QueueGuard guard(_queue_mutex);
for (const auto& h : _pending_drops) {
if (h == message_hash) return;
}
_pending_drops.push_back(message_hash);
}
void ConversationListScreen::flush_pending_drops() {
// Called from UIManager::update() BEFORE it takes the LVGL lock.
// Drop the queued unreadable messages so the conversation index
// converges (deletion updates last_message_hash to the new tail); the
// next list refresh then previews the newest readable message. A
// delete that fails (store not ready) is requeued for a later drain,
// deduped against any request that arrived during the I/O. Fail
// closed (no draining) if the mutex is unavailable.
if (!_queue_mutex) {
return;
}
std::vector<Bytes> batch;
{
QueueGuard guard(_queue_mutex);
if (_pending_drops.empty()) {
return;
}
batch.swap(_pending_drops);
}
if (!_message_store) {
// Store not ready: hand the batch back (merged, deduped) so
// nothing is lost while the store comes up.
QueueGuard guard(_queue_mutex);
for (const auto& h : batch) {
bool present = false;
for (const auto& q : _pending_drops) {
if (q == h) {
present = true;
break;
}
}
if (!present) {
_pending_drops.push_back(h);
}
}
return;
}
std::vector<Bytes> remaining;
for (const auto& h : batch) {
if (!_message_store->delete_message(h)) {
remaining.push_back(h);
}
}
if (!remaining.empty()) {
QueueGuard guard(_queue_mutex);
for (const auto& h : remaining) {
bool present = false;
for (const auto& q : _pending_drops) {
if (q == h) {
present = true;
break;
}
}
if (!present) {
_pending_drops.push_back(h);
}
}
}
}
void ConversationListScreen::flush_pending_index_commit() {
// Called from UIManager::update() BEFORE it takes the LVGL lock,
// right after draining drops. Commits the persisted conversation
// index once per boot when refresh() had to fall back to
// load_message_metadata() (unpopulated preview cache): the in-memory
// repop would otherwise be lost on reboot and the next cold boot
// would re-read every newest message file again. save_index()
// rewrites the whole index, so it also persists any preview cache
// writes made by save_message() since the last store-originated
// commit — one-shot by design; the flag clears even if the commit
// fails and a later fallback re-arms it.
// refresh() (LVGL task) sets it; the main-loop drain consumes it —
// test-and-clear under _queue_mutex (a lost set would skip the
// one-shot index commit and cost every boot the full fallback pass).
// Fail closed (no commit drain) if the mutex is unavailable.
if (!_queue_mutex) {
return;
}
bool pending;
{
QueueGuard guard(_queue_mutex);
pending = _index_commit_pending;
_index_commit_pending = false;
}
if (!_message_store || !pending) {
return;
}
_message_store->commit_index();
}
void ConversationListScreen::clear_unread_badge(lv_obj_t* container) {
// Remove the unread badge from a rendered conversation row. The badge
// is tracked in _badge_pool in lockstep with _conversation_containers,
// so locate the row by container and delete its stored badge object.
// (LVGL 8.4's lv_obj_t is opaque — no public child iteration — so a
// side table is the portable way to reach the badge on click.)
for (size_t i = 0; i < _conversation_containers.size(); ++i) {
if (_conversation_containers[i] == container) {
if (i < _badge_pool.size() && _badge_pool[i]) {
lv_obj_del(_badge_pool[i]);
_badge_pool[i] = nullptr;
}
return;
}
}
}
@@ -655,6 +1023,11 @@ void ConversationListScreen::on_conversation_clicked(lv_event_t* event) {
Bytes* peer_hash = (Bytes*)lv_obj_get_user_data(target);
if (peer_hash && screen->_conversation_selected_callback) {
// Clear the badge in the UI now; the store commit is deferred to
// UIManager::update() (mark_conversation_read() writes the
// LittleFS index and must not run under the LVGL lock).
screen->clear_unread_badge(target);
screen->request_mark_read(*peer_hash);
screen->_conversation_selected_callback(*peer_hash);
}
}
+70 -4
View File
@@ -102,11 +102,50 @@ public:
void flush_pending_name_writes();
/**
* Update unread count for a specific conversation
* @param peer_hash Peer hash
* @param unread_count New unread count
* Request that a conversation be marked read. Defers the store
* mutation (LittleFS index commit) out of the LVGL event callback;
* MUST be drained by flush_pending_mark_reads() OUTSIDE the LVGL
* lock (UIManager::update() calls it before taking the lock), the
* same pattern as the deferred display-name write-throughs.
*/
void update_unread_count(const RNS::Bytes& peer_hash, uint16_t unread_count);
void request_mark_read(const RNS::Bytes& peer_hash);
/**
* Commit pending mark-read requests to the store. MUST be called
* OUTSIDE the LVGL lock.
*/
void flush_pending_mark_reads();
/**
* Remove the unread badge from a rendered conversation row.
*/
void clear_unread_badge(lv_obj_t* container);
/**
* Queue a corrupt/unreadable message for deletion. The actual
* store mutation (index commit + payload removal) is drained by
* flush_pending_drops() OUTSIDE the LVGL lock, like mark-read.
*/
void request_drop_message(const RNS::Bytes& message_hash);
/**
* Drop queued unreadable messages from the store. MUST be called
* OUTSIDE the LVGL lock.
*/
void flush_pending_drops();
/**
* Commit the persisted conversation index once when this boot has
* fallen back to message-file reads (the one-shot preview-cache
* warm-up: cold boot after a firmware/index upgrade, or a tail
* cleared by a delete). The in-memory repop alone is lost on
* reboot, so without this commit the first list refresh after
* EVERY boot re-reads every newest message file. MUST be called
* OUTSIDE the LVGL lock (UIManager::update() calls it after
* draining drops, before mark-read, so all three land in one
* sensible order — each save_index() rewrites the whole index).
*/
void flush_pending_index_commit();
/**
* Set callback for conversation selection
@@ -213,6 +252,7 @@ private:
::LXMF::MessageStore* _message_store;
std::vector<ConversationItem> _conversations;
std::vector<lv_obj_t*> _conversation_containers; // For focus group management
std::vector<lv_obj_t*> _badge_pool; // Unread badge per row (nullptr when none); index-aligned with _conversation_containers
std::vector<RNS::Bytes> _peer_hash_pool; // Object pool to avoid per-item allocations
RNS::Bytes _pending_delete_hash; // Hash of conversation pending deletion
bool _has_unresolved_names = false; // True if any conversation shows hash instead of name
@@ -221,6 +261,32 @@ private:
// under the lock stalls the render task on a cold-boot announce burst.
// Drained by UIManager::update() before it takes the lock.
std::vector<std::pair<RNS::Bytes, std::string>> _pending_name_writes;
// Mark-read requests deferred the same way: the LVGL click handler
// only queues the hash; UIManager::update() commits it (LittleFS
// index write) before taking the lock.
std::vector<RNS::Bytes> _pending_mark_reads;
// Corrupt/unreadable messages queued for deletion (one per unreadable
// tail walk, bounded per refresh); UIManager::update() drops them
// before taking the lock so the index converges to the newest
// readable message.
std::vector<RNS::Bytes> _pending_drops;
// Set by refresh() when at least one conversation fell back to
// load_message_metadata() this boot (unpopulated preview cache:
// cold boot on a pre-c8d3156-generation index, or a tail cleared by
// delete). flush_pending_index_commit() persists the repop once so
// the next boot serves previews straight from the index.
bool _index_commit_pending = false;
// Guards _pending_name_writes / _pending_mark_reads / _pending_drops
// / _index_commit_pending: producers run under the LVGL lock (LVGL
// task: click handlers, refresh() during navigation) while the
// flush_*() consumers run on the main loop WITHOUT the LVGL lock
// (their store I/O must not run under it). Critical sections hold
// no LVGL lock, no store I/O, and never block — a brief vector
// swap/push — so portMAX_DELAY acquisition cannot deadlock or stall
// a task. NULL (allocation failure) means every guarded site fails
// closed: queue mutation is refused and the affected features
// degrade rather than run unsynchronized.
SemaphoreHandle_t _queue_mutex = nullptr;
ConversationSelectedCallback _conversation_selected_callback;
ComposeCallback _compose_callback;
+21
View File
@@ -719,6 +719,18 @@ void UIManager::update() {
// never runs under the render lock (same reason as on_message_received).
if (_conversation_list_screen) {
_conversation_list_screen->flush_pending_name_writes();
// Drop queued corrupt messages (index commit + payload removal).
// Drained before mark-read so both index commits land in a
// sensible order — each save_index() rewrites the whole index,
// and mark-read's final write carries the cleared unread count.
_conversation_list_screen->flush_pending_drops();
// One-shot preview-cache warm-up commit (index carries
// last_preview now; see the method). Lands before mark-read so
// that final index write also carries the warmed previews.
_conversation_list_screen->flush_pending_index_commit();
// Commit queued mark-read index writes the same way — the LVGL
// click handler only recorded the peer hash.
_conversation_list_screen->flush_pending_mark_reads();
}
// SERVICE ORDER CONTRACT: consume peer consent commands and complete the
@@ -790,6 +802,9 @@ void UIManager::update() {
// whole page blocking it past LVGLLock's 5s timeout.
if (_navigation.current() == Route::CHAT && _chat_screen) {
_chat_screen->tick_background_fill();
// Long-press full-message view: the LVGL event handler only records
// the hash; the disk read + modal build run here on the main loop.
_chat_screen->tick_pending_full_message();
}
// Gather + render the announce list off the LVGL lock on the main loop (its
// refresh() just arms a flag). Iterating the path table on the LVGL task raced
@@ -1783,6 +1798,12 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
bool viewing_this_chat = (_navigation.current() == Route::CHAT && _current_peer_hash == message.source_hash());
if (viewing_this_chat) {
_chat_screen->add_message(message, false);
// The user is watching this conversation land, so it's read.
// Defers the index commit to UIManager::update() like the
// click-path badge clear.
if (_conversation_list_screen) {
_conversation_list_screen->request_mark_read(message.source_hash());
}
}
// Play notification sound if enabled and not viewing this conversation
+28 -1
View File
@@ -117,7 +117,34 @@ lib_deps =
; 60fb7d6: merged non-evicting guarded outbound admission,
; persist-only-after-preparation semantics, and serialized native bridge
; transport callbacks/conformance closure from microLXMF PR #9.
https://github.com/torlando-tech/microLXMF.git#60fb7d6951bd17275da83fdd9581900d55b0a2ab
; df1b01a (merged feat/conversation-last-message-accessors, microLXMF PR #10):
; O(1) per-conversation get_last_message_hash + get_conversation_unread_count
; — the conversation list reads the newest hash from the index tail
; instead of copying the full 8KB hash array, and renders the real
; persisted unread badge.
; c8d3156 (feat/conversation-preview-cache, microLXMF PR #11): last-message
; preview + timestamp cached per conversation in the index — list refresh
; becomes O(1) in-memory with zero message-file reads (one fallback
; write-through per conversation when the index predates the field).
; e54a102 (same branch): preview copy bounded (no strncpy over-read past
; the content Bytes); empty-content tails (location shares, blank pings)
; cached as a valid empty preview via a preview_valid index flag —
; callers fall back only when the cache is unpopulated.
; 6bea23c (same branch): public commit_index() so the list's one-shot
; preview warm-up persists to the index without a save/delete.
; bb9d387 (same branch): bounded in-process message-metadata cache in
; MessageStore (64-slot FIFO, file scope) — chat reopen,
; background page fill, and pagination no longer re-read message
; files from SPI; state kept in sync by update_message_state(),
; eviction on delete_message().
; 8e8e689 (same branch): load_message_content() full-view accessor —
; the long-press full-message view reads the complete stored text
; (uncapped) off the metadata-cache display cap.
; 3438222/58a6eb0/d7e05fd (same branch): the cache table is PSRAM-allocated on
; ESP32 (heap_caps_malloc/MALLOC_CAP_SPIRAM; static .bss placement
; starved internal DRAM and the LVGL task failed to create at boot);
; host builds keep the static table.
https://github.com/torlando-tech/microLXMF.git#d7e05fdc10fe11bca0c4e3d8c0214b0be7d9a911
lvgl/lvgl@8.4.0
bblanchon/ArduinoJson@^7.4.2
hideakitai/MsgPack@^0.4.2
+26
View File
@@ -41,3 +41,29 @@ target_compile_options(test_pyxis_telemetry_field PRIVATE
target_link_options(test_pyxis_telemetry_field PRIVATE
-fsanitize=address,undefined
)
# Host-side benchmark of the conversation-list store load path (the per-
# conversation work ConversationListScreen::refresh() performs). Builds
# against the exact pinned microLXMF source, so before/after numbers are
# comparable across pin bumps. BENCH_INDEX_ACCESSORS is added by the
# harness only when the pinned store supports in-memory preview accessors.
add_executable(bench_conversation_list_load
bench_conversation_list_load.cpp
)
find_package(Threads REQUIRED)
target_link_libraries(bench_conversation_list_load PRIVATE MicroLXMFLib Threads::Threads)
target_compile_options(bench_conversation_list_load PRIVATE
-fexceptions
-Wall -Wno-unused-parameter -Wno-missing-field-initializers
)
target_compile_definitions(bench_conversation_list_load PRIVATE
MICROLXMF_NATIVE=1
NATIVE=1
)
# BENCH_INDEX_ACCESSORS=1 only when the pinned store exposes the in-memory
# preview accessors (get_last_message_preview); otherwise the binary reports
# that it is baseline-only.
option(BENCH_INDEX_ACCESSORS "pin supports in-memory preview accessors" OFF)
if(BENCH_INDEX_ACCESSORS)
target_compile_definitions(bench_conversation_list_load PRIVATE BENCH_INDEX_ACCESSORS=1)
endif()
@@ -0,0 +1,328 @@
// Benchmark: conversation-list store load path (the work ConversationListScreen::refresh()
// does per conversation), compiled against the EXACT pinned microLXMF source in .pio/libdeps.
//
// Modes:
// baseline : get_conversations() + per-conversation get_last_message_hash() +
// load_message_metadata(last_hash) + get_display_name() +
// get_conversation_unread_count() (== PR #95 refresh() I/O)
// index : same, but preview/timestamp come from in-memory index accessors when
// available (compiled only when the pinned store supports them)
//
// Prints median-of-iterations wall time per mode so a before/after table can be built.
#include <LXMF/MessageStore.h>
#include <LXMF/LXMessage.h>
#include <microReticulum/Bytes.h>
#include <microReticulum/Utilities/OS.h>
#include <microStore/Adapters/PosixFileSystem.h>
#include <microStore/File.h>
#include <microStore/FileSystem.h>
#include <fcntl.h>
#include <dirent.h>
#include <errno.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <numeric>
#include <string>
#include <sys/stat.h>
#include <sys/types.h>
#include <vector>
using LXMF::MessageStore;
using LXMF::LXMessage;
using RNS::Bytes;
// ---------- prefixing filesystem (mirror of test_messagestore_tiers) ----------
namespace bench_fs {
class PrefixedFileImpl : public microStore::FileImpl {
private:
int _fd;
bool _closed;
public:
explicit PrefixedFileImpl(int fd) : microStore::FileImpl(), _fd(fd), _closed(false) {}
~PrefixedFileImpl() override { if (!_closed) close(); }
const char* name() const override { return ""; }
size_t size() const override { struct stat st; ::fstat(_fd, &st); return st.st_size; }
void close() override { if (!_closed) { ::close(_fd); _closed = true; } }
int read() override { uint8_t b; if (::read(_fd, &b, 1) != 1) return -1; return b; }
int peek() override { return -1; }
size_t read(uint8_t* buf, size_t sz) override {
ssize_t n = ::read(_fd, buf, sz); return n < 0 ? 0 : (size_t)n;
}
size_t write(uint8_t b) override { return ::write(_fd, &b, 1) == 1 ? 1 : 0; }
size_t write(const uint8_t* buf, size_t sz) override {
ssize_t n = ::write(_fd, buf, sz); return n < 0 ? 0 : (size_t)n;
}
int available() override { return 0; }
size_t tell() override { return ::lseek(_fd, 0, SEEK_CUR); }
long seek(uint32_t pos, microStore::SeekMode m) override {
int wh = SEEK_SET;
if (m == microStore::SeekMode::SeekModeCur) wh = SEEK_CUR;
else if (m == microStore::SeekMode::SeekModeEnd) wh = SEEK_END;
return ::lseek(_fd, pos, wh);
}
void flush() override {}
bool isValid() const override { return !_closed && _fd >= 0; }
};
class PrefixedFSImpl : public microStore::FileSystemImpl {
private:
std::string _prefix;
std::string fp(const char* path) const { return _prefix + path; }
void mkparents(const std::string& path) const {
size_t p = 0;
while ((p = path.find('/', p + 1)) != std::string::npos) {
std::string dir = path.substr(0, p);
::mkdir(dir.c_str(), 0755);
}
}
public:
explicit PrefixedFSImpl(const std::string& prefix) : _prefix(prefix) {
::mkdir(prefix.c_str(), 0755);
}
bool init(bool) override { return true; }
bool format() override { return false; }
microStore::File open(const char* path, microStore::File::Mode mode,
const bool create = false) override {
std::string full = fp(path);
int flags;
switch (mode) {
case microStore::File::ModeRead: flags = O_RDONLY; break;
case microStore::File::ModeWrite: flags = O_WRONLY|O_CREAT|O_TRUNC; break;
case microStore::File::ModeAppend: flags = O_WRONLY|O_CREAT|O_APPEND; break;
case microStore::File::ModeReadWrite: flags = O_RDWR|O_CREAT|O_TRUNC; break;
case microStore::File::ModeReadAppend: flags = O_RDWR|O_CREAT|O_APPEND; break;
default: return {};
}
if (flags & O_CREAT) mkparents(full);
int fd = ::open(full.c_str(), flags, 0644);
if (fd == -1) return {};
return microStore::File(new PrefixedFileImpl(fd));
}
bool exists(const char* path) override { struct stat st; return ::stat(fp(path).c_str(), &st) == 0; }
bool remove(const char* path) override { return ::unlink(fp(path).c_str()) == 0; }
bool rename(const char* a, const char* b) override { return ::rename(fp(a).c_str(), fp(b).c_str()) == 0; }
bool mkdir(const char* path) override {
std::string full = fp(path); mkparents(full);
return ::mkdir(full.c_str(), 0755) == 0 || errno == EEXIST;
}
bool rmdir(const char* path) override { return ::rmdir(fp(path).c_str()) == 0; }
size_t size(const char* path) override { struct stat st; if (::stat(fp(path).c_str(), &st) != 0) return 0; return st.st_size; }
bool isDirectory(const char* path) override {
struct stat st; if (::stat(fp(path).c_str(), &st) != 0) return false;
return S_ISDIR(st.st_mode);
}
std::list<std::string> listDirectory(const char* path,
Callbacks::DirectoryListing cb = nullptr) override {
std::list<std::string> out;
DIR* d = ::opendir(fp(path).c_str());
if (!d) return out;
while (auto* ent = ::readdir(d)) {
if (ent->d_name[0] == '.') continue;
if (cb) cb(ent->d_name); else out.push_back(ent->d_name);
}
::closedir(d);
return out;
}
size_t storageSize() override { return 0; }
size_t storageAvailable() override { return 0; }
};
class PrefixedFS : public microStore::FileSystem {
public:
explicit PrefixedFS(const std::string& prefix)
: microStore::FileSystem(new PrefixedFSImpl(prefix)) {}
};
} // namespace bench_fs
// ---------- synthetic messages (mirror of test_messagestore_tiers) ----------
static Bytes make_msgpack_payload(double timestamp,
const std::string& title,
const std::string& content) {
Bytes p;
p.append((uint8_t)0x94); // arr_size 4
p.append((uint8_t)0xcb);
union { double d; uint64_t u; } cv;
cv.d = timestamp;
for (int i = 7; i >= 0; --i) p.append((uint8_t)((cv.u >> (i * 8)) & 0xff));
p.append((uint8_t)0xc4);
p.append((uint8_t)title.size());
p.append((const uint8_t*)title.data(), title.size());
if (content.size() < 256) {
p.append((uint8_t)0xc4);
p.append((uint8_t)content.size());
} else {
p.append((uint8_t)0xc5);
p.append((uint8_t)((content.size() >> 8) & 0xff));
p.append((uint8_t)(content.size() & 0xff));
}
p.append((const uint8_t*)content.data(), content.size());
p.append((uint8_t)0x80); // empty fields map
return p;
}
static LXMessage make_message(const Bytes& peer_hash, const Bytes& self_hash,
double timestamp, const std::string& content,
bool incoming) {
// save_message() keys a conversation by source for incoming,
// destination for outgoing — set the endpoints so the conversation
// always lands under peer_hash regardless of direction.
const Bytes& dest = incoming ? self_hash : peer_hash;
const Bytes& src = incoming ? peer_hash : self_hash;
Bytes raw;
raw.append(dest.data(), 16);
raw.append(src.data(), 16);
for (int i = 0; i < 64; ++i) raw.append((uint8_t)0);
Bytes payload = make_msgpack_payload(timestamp, "t", content);
raw.append(payload.data(), payload.size());
LXMessage m = LXMessage::unpack_from_bytes(raw, LXMF::Type::Message::DIRECT, true);
m.incoming(incoming);
return m;
}
// ---------- seed a store shaped like a real T-Deck: N conversations ----------
static void seed_store(MessageStore& store, int convs, int msgs_per_conv) {
double base_ts = 1750000000.0;
for (int c = 0; c < convs; ++c) {
Bytes peer(16), self(16);
for (int i = 0; i < 16; ++i) {
peer.append((uint8_t)(0xa0 + c * 7 + i));
self.append((uint8_t)(0x50 + i));
}
store.set_display_name(peer, "peer_" + std::to_string(c));
for (int m = 0; m < msgs_per_conv; ++m) {
// ~240-char content: realistic chat text plus a tail so previews
// exercise truncation the same way the list does.
std::string content =
"Check in when you land, the uplink window was tight today ";
while (content.size() < 200) content += "and the router was hopping paths. ";
content = content.substr(0, 220);
double ts = base_ts + (c * 5000 + m * 3600);
LXMessage msg = make_message(peer, self, ts, content, m % 3 != 0);
if (!store.save_message(msg)) {
std::cerr << "seed save failed c=" << c << " m=" << m << "\n";
std::exit(2);
}
}
}
}
// ---------- the two load paths ----------
// baseline == exactly the per-conversation work of PR #95 refresh()
static double run_baseline(MessageStore& store) {
using Clock = std::chrono::steady_clock;
auto t0 = Clock::now();
std::vector<Bytes> peers = store.get_conversations();
for (const auto& peer : peers) {
Bytes last = store.get_last_message_hash(peer);
if (!last) continue;
MessageStore::MessageMetadata meta = store.load_message_metadata(last);
if (!meta.valid) continue;
std::string preview = meta.content.substr(0, 30); // the 30-char preview
double ts = meta.timestamp;
std::string name = store.get_display_name(peer);
size_t unread = store.get_conversation_unread_count(peer);
(void)preview; (void)ts; (void)name; (void)unread;
}
auto t1 = Clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count();
}
#ifdef BENCH_INDEX_ACCESSORS
// index == store provides in-memory per-conversation preview/timestamp; no
// message-file I/O on the hot path.
static double run_index(MessageStore& store) {
using Clock = std::chrono::steady_clock;
auto t0 = Clock::now();
std::vector<Bytes> peers = store.get_conversations();
for (const auto& peer : peers) {
Bytes last = store.get_last_message_hash(peer);
if (!last) continue;
std::string preview;
double ts = 0.0;
bool from_index = store.get_last_message_preview(peer, preview, ts);
MessageStore::MessageMetadata meta;
meta.valid = false;
if (!from_index) {
meta = store.load_message_metadata(last); // fallback (no cached preview)
if (!meta.valid) continue;
preview = meta.content.substr(0, 30);
ts = meta.timestamp;
}
std::string name = store.get_display_name(peer);
size_t unread = store.get_conversation_unread_count(peer);
(void)preview; (void)ts; (void)name; (void)unread;
}
auto t1 = Clock::now();
return std::chrono::duration<double, std::milli>(t1 - t0).count();
}
#endif
static double median(std::vector<double> v) {
std::sort(v.begin(), v.end());
if (v.empty()) return 0.0;
return v[v.size() / 2];
}
int main(int argc, char** argv) {
std::string root = argc > 1 ? argv[1] : "/tmp/bench-convlist";
int convs = argc > 2 ? std::atoi(argv[2]) : 9;
int msgs = argc > 3 ? std::atoi(argv[3]) : 24;
int iters = argc > 4 ? std::atoi(argv[4]) : 25;
::system(("rm -rf " + root + " && mkdir -p " + root + "/hot").c_str());
bench_fs::PrefixedFS hot_fs(root + "/hot");
RNS::Utilities::OS::register_filesystem(hot_fs);
std::vector<double> baseline_ms;
std::vector<double> index_warm_ms;
std::vector<double> index_cold_ms;
{
MessageStore store("/lxmf");
seed_store(store, convs, msgs);
for (int i = 0; i < iters; ++i) baseline_ms.push_back(run_baseline(store));
#ifdef BENCH_INDEX_ACCESSORS
// Warm path: the store the app holds right now (in-memory index
// includes the preview cache).
for (int i = 0; i < iters; ++i) index_warm_ms.push_back(run_index(store));
#endif
} // drop the in-memory store (reboot shape)
#ifdef BENCH_INDEX_ACCESSORS
{
// Cold-boot path: reconstruct the store from disk (reboot / fresh
// firmware boot). The index on disk now carries last_preview (new
// pin), so the first refresh still hits the cache with zero
// message-file reads — that is the whole point of persisting it.
MessageStore store("/lxmf");
for (int i = 0; i < iters; ++i) index_cold_ms.push_back(run_index(store));
}
#endif
std::cout << "convlist-bench convs=" << convs << " msgs_per_conv=" << msgs
<< " iters=" << iters << "\n";
std::cout << "baseline_ms=" << median(baseline_ms)
<< " (min=" << *std::min_element(baseline_ms.begin(), baseline_ms.end())
<< " max=" << *std::max_element(baseline_ms.begin(), baseline_ms.end()) << ")\n";
#ifdef BENCH_INDEX_ACCESSORS
std::cout << "index_warm_ms=" << median(index_warm_ms)
<< " (min=" << *std::min_element(index_warm_ms.begin(), index_warm_ms.end())
<< " max=" << *std::max_element(index_warm_ms.begin(), index_warm_ms.end()) << ")\n";
std::cout << "index_cold_ms=" << median(index_cold_ms)
<< " (min=" << *std::min_element(index_cold_ms.begin(), index_cold_ms.end())
<< " max=" << *std::max_element(index_cold_ms.begin(), index_cold_ms.end()) << ")\n";
#endif
std::cout << "convlist-bench: done\n";
return 0;
}
@@ -22,7 +22,7 @@ def test_pinned_microlxmf_telemetry_field_roundtrip(tmp_path):
if not (source / "LXMF" / "LXMessage.cpp").is_file():
pytest.fail(f"pinned microLXMF source is unavailable at {microlxmf}")
expected = "5a44862b85e902f351b756ee00482f3258b244f5"
expected = "d7e05fdc10fe11bca0c4e3d8c0214b0be7d9a911"
actual = subprocess.run(
["git", "-C", str(microlxmf), "rev-parse", "HEAD"],
capture_output=True,
+1 -1
View File
@@ -46,7 +46,7 @@ EXCLUDED_SYMBOLS = (
)
PINNED_DEPENDENCIES = {
"microReticulum": "cd0338e7fc07d3a7785a450656ba766491cbf6e8",
"microLXMF": "60fb7d6951bd17275da83fdd9581900d55b0a2ab",
"microLXMF": "d7e05fdc10fe11bca0c4e3d8c0214b0be7d9a911",
"microStore": "2762f7606800ffb23f4a593947d4f58e259cda7a",
}