From 4058f8dcd940d0d58ae932074e2b9437b4e6cc93 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Thu, 30 Jul 2026 17:13:05 +0200 Subject: [PATCH] touch: stop the inbox rescanning the whole message ring per thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: a public channel that had accumulated 3800 messages made the interface "slow down drastically", and clearing it restored normal speed. The reporter also noticed it was still noticeable at only ~300 messages and asked whether something loops over every message. It does. threadHasMessageHistory() answered "does this thread have any stored message?" by walking the ring via getThreadMessageIndexes(). That scan stops early once it finds a match, so a thread WITH recent messages is cheap — but a thread with none walks every record in the ring doing a strncmp per record, and "has no history" is precisely what the callers are testing for. getCombinedInboxCount() and getUnreadTotal() both call it once PER THREAD, so a full ring turned every inbox refresh into threads x messages string compares. That is the reported slowdown, and it explains why it is felt well below a full ring. Now cached per thread: - an append sets the owning thread's flag directly, so the common path never scans at all; - anything that REMOVES messages (ring eviction, clearThreadHistory, removeThread, any history load) marks the cache dirty, and the next reader rebuilds every thread's flag in ONE ring pass instead of one pass per thread, stopping as soon as all live threads are resolved. clearThreadHistory clears just its own flag (no other thread is touched), so the common "clear one chat" action stays O(ring) once rather than forcing a rebuild. Behaviour is unchanged; this only removes repeated work. The separate request from the same thread — a configurable per-channel history limit with a lower default and a warning — is NOT in this commit. Builds clean on V4 + T-Deck; flashed to the T-Deck. Co-Authored-By: Claude Opus 4.8 --- src/ui-touch/UITask.cpp | 37 +++++++++++++++++++++++++++++++++++-- src/ui-touch/UITask.h | 17 +++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5e0dda0..b0051da 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -41914,6 +41914,7 @@ bool UITask::loadMsgsFromStorage() { f.close(); _ui_msg_count = 0; _ui_msg_head = 0; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast(hdr.msgcount); return true; } @@ -41954,6 +41955,7 @@ bool UITask::loadMsgsFromStorage() { _ui_msg_count = n_keep; // ring is now linear: slots 0..n_keep-1, oldest first _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _msgcount = static_cast(hdr.msgcount); return true; #else @@ -42075,6 +42077,7 @@ bool UITask::loadMsgsFromSegments() { _ui_msg_count = n_keep; _ui_msg_head = n_keep % _ui_msg_cap; + _thread_hist_dirty = true; // ring replaced — recompute "has history" on next read _ui_seq_next = max_seq + 1; s_seg_flushed_seq = max_seq; // everything loaded IS on disk by definition // _msgcount isn't stored in segments; the threads-file header carries it and @@ -43115,6 +43118,7 @@ int UITask::findOrCreateThread(const char* name, bool channel) { bool UITask::removeThread(int idx) { if (idx < 0 || idx >= MAX_UI_THREADS) return false; if (!_ui_threads[idx].used) return false; + _thread_hist_dirty = true; // slot is being freed + its ring records dropped // Match the thread by stored name (channel/DM) before wiping the slot so // we can drop any ring messages that belonged to it. Without this the // entries would still occupy the bounded _ui_msgs ring and confuse a @@ -43387,6 +43391,11 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te m.sender[MAX_SENDER_NAME] = '\0'; strncpy(m.text, text ? text : "", MAX_MSG_TEXT); m.text[MAX_MSG_TEXT] = '\0'; + // An append is the ONLY mutation that needs no scan: this thread now demonstrably has + // history. A full ring also EVICTED the oldest record above, which may have been some other + // thread's last message, so the cached flags have to be rebuilt before they are trusted again. + if (_ui_msg_count == _ui_msg_cap) _thread_hist_dirty = true; + _thread_hist[t_idx] = true; if (_ui_msg_count < _ui_msg_cap) ++_ui_msg_count; _ui_msg_head = (_ui_msg_head + 1) % _ui_msg_cap; _ui_threads[t_idx].last_ts = m.ts; @@ -44651,10 +44660,33 @@ int UITask::getThreadCount(bool channel_mode, int out_indexes[], int max_out) co return copy_n; } +// One ring pass that answers "has any message?" for EVERY thread at once (see the note on +// _thread_hist). Stops as soon as every live thread is accounted for, so a device whose +// threads all have recent traffic barely reads the ring at all. +void UITask::rebuildThreadHistoryFlags() const { + for (int i = 0; i < MAX_UI_THREADS; ++i) _thread_hist[i] = false; + int unresolved = 0; + for (int i = 0; i < MAX_UI_THREADS; ++i) if (_ui_threads[i].used) ++unresolved; + for (int i = 0; i < _ui_msg_count && unresolved > 0; ++i) { + const int idx = (_ui_msg_head - 1 - i + _ui_msg_cap) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[idx]; + for (int t = 0; t < MAX_UI_THREADS; ++t) { + if (!_ui_threads[t].used || _thread_hist[t]) continue; + if (m.channel == _ui_threads[t].channel && + strncmp(m.thread, _ui_threads[t].name, MAX_THREAD_NAME) == 0) { + _thread_hist[t] = true; + --unresolved; + break; + } + } + } + _thread_hist_dirty = false; +} + bool UITask::threadHasMessageHistory(int thread_idx) const { if (thread_idx < 0 || thread_idx >= MAX_UI_THREADS || !_ui_threads[thread_idx].used) return false; - int tmp[8]; - return getThreadMessageIndexes(thread_idx, tmp, 8, false) > 0; + if (_thread_hist_dirty) rebuildThreadHistoryFlags(); + return _thread_hist[thread_idx]; } int UITask::getCombinedInboxCount(int out_indexes[], int max_out) const { @@ -44736,6 +44768,7 @@ int UITask::clearThreadHistory(int thread_idx) { } _ui_threads[thread_idx].unread = 0; _ui_threads[thread_idx].has_mention = false; + _thread_hist[thread_idx] = false; // emptied, and no other thread was touched _threads_dirty = true; if (cleared) _msgs_dirty = true; return cleared; diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index a2ec856..b432495 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -147,6 +147,23 @@ private: * else MAX_UI_MESSAGES. Fixed for the whole boot (chosen before the PSRAM * alloc in begin()); the loader linearizes files written under another cap. */ int _ui_msg_cap = MAX_UI_MESSAGES; + /** "Does this thread have any stored message?", answered in O(1). + * + * threadHasMessageHistory() used to answer it by walking the message ring looking for a + * match, and the inbox/unread paths call it once PER THREAD. A thread that HAS recent + * messages exits that scan early; a thread with none scans every record in the ring doing + * a strncmp each — and "no history" is exactly what those callers are testing for. With a + * busy public channel filling the ring (a field report: 3800 messages) each inbox refresh + * became tens of thousands of string compares, which is the reported "interface slows down + * drastically", and why it was still felt at only ~300 messages. + * + * Appends set the owning thread's flag directly, so the common path never scans. Anything + * that REMOVES messages (ring eviction, delete, clear, a fresh load) can only invalidate + * the flags, so it just marks them dirty and the next reader rebuilds all threads in ONE + * ring pass instead of one pass per thread. */ + mutable bool _thread_hist[MAX_UI_THREADS] = { false }; + mutable bool _thread_hist_dirty = true; + void rebuildThreadHistoryFlags() const; unsigned long _next_thread_seed; UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin() UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin()