mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-10-28 08:42:38 +00:00
Merge pull request #95 from torlando-tech/perf/conversation-list-load
perf(messages): fast conversation-list load + real unread badges
This commit is contained in:
11 files changed
+978
-68
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,7 +46,7 @@ EXCLUDED_SYMBOLS = (
|
||||
)
|
||||
PINNED_DEPENDENCIES = {
|
||||
"microReticulum": "cd0338e7fc07d3a7785a450656ba766491cbf6e8",
|
||||
"microLXMF": "60fb7d6951bd17275da83fdd9581900d55b0a2ab",
|
||||
"microLXMF": "d7e05fdc10fe11bca0c4e3d8c0214b0be7d9a911",
|
||||
"microStore": "2762f7606800ffb23f4a593947d4f58e259cda7a",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user