// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 // // ConsoleModel — the thread-safe line store behind ConsoleTab. // // The console is fed from multiple threads: user commands + drained daemon/lite log lines // arrive on the main (render) thread, but RPC-trace and app-logger lines can be produced on // background worker threads. Previously a single mutex guarded both the line container AND // the UI-only interaction state (selection, scroll, auto-scroll counters), and it was held // across the whole render pass — yet some of that UI state was still touched outside the // lock in render(), racing the background producers. // // ConsoleModel splits the two concerns. Producers on ANY thread call ingest(), which only // briefly locks a small pending queue. The main thread calls drain() once per frame to move // pending lines into the visible deque and apply the line cap. The visible deque is then // only ever read/written on the main thread, so ConsoleTab needs no lock during render and // its selection/scroll fields become pure main-thread state. #pragma once #include "console_channel.h" #include #include #include #include #include #include namespace dragonx { namespace ui { struct ConsoleModelLine { std::string text; ConsoleChannel channel = ConsoleChannel::None; // JSON folding: for a line that opens a collapsible block, `foldSpan` is the offset to // its matching closing-bracket line (>= 2); 0 for non-openers. `collapsed` is the user's // fold toggle (main-thread UI state). int foldSpan = 0; bool collapsed = false; }; class ConsoleModel { public: static constexpr std::size_t kDefaultMaxLines = 10000; // Pending (not yet drained) text is bounded too: lines beyond max_lines_ would be evicted by drain() anyway, // and a byte budget stops a hidden Console from retaining verbose daemon-log chunks (<=4 KB each) forever. static constexpr std::size_t kDefaultMaxPendingBytes = 4u * 1024u * 1024u; explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines, std::size_t maxPendingBytes = kDefaultMaxPendingBytes) : max_lines_(maxLines), max_pending_bytes_(maxPendingBytes) {} std::size_t maxLines() const { return max_lines_; } // Append a line from ANY thread. Cheap: locks only the pending queue, does not touch // the visible deque. The line becomes visible after the next drain(). `foldSpan` marks a // JSON block opener (offset to its matching closer); 0 for ordinary lines. void ingest(const std::string& text, ConsoleChannel channel, int foldSpan = 0); // Append a contiguous block (e.g. one formatted RPC result) from ANY thread under ONE lock acquisition, so // lines from other producers can never land inside it (foldSpan offsets are relative to the opener). // Never calls the Logger (Logger::write invokes its callback while holding Logger::mutex_, i.e. the lock // order is Logger::mutex_ -> ingest_mutex_; logging from in here would self-deadlock). void ingestBatch(std::vector&& lines); struct DrainResult { std::size_t added = 0; // lines appended to the visible deque this drain std::size_t popped = 0; // lines evicted from the front by the line cap std::size_t dropped = 0; // oldest pending lines discarded by the pending cap/byte budget since last drain }; // Main thread only. Move pending lines into the visible deque, then enforce the cap. // Returns how many were added and how many were evicted from the front (so the caller // can shift any selection indices that referenced the evicted lines). DrainResult drain(); // Main thread only. Clear the visible deque. Does NOT discard queued pending lines — // new output still appears — matching the console's view-only `clear` semantics. void clear(); // Main thread only. Toggle a foldable line's collapsed state (no-op if out of range or // not a block opener). Returns the new collapsed state. bool toggleCollapsed(std::size_t i); // Visible-model access — main thread only (no lock; the deque is main-thread-owned). const std::deque& lines() const { return lines_; } std::size_t size() const { return lines_.size(); } bool empty() const { return lines_.empty(); } const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; } const ConsoleModelLine& back() const { return lines_.back(); } // Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear, // fold toggle). The view memoizes its per-frame filter + text-layout passes against this. std::uint64_t revision() const { return revision_; } // Absolute sequence number of lines_.front(): +1 per line evicted from the front by the cap, +size() on // clear(). Line text never changes after ingest, so a view cache keyed by sequence number realigns with // one subtraction (eviction becomes an offset, not a rebuild). Never decreases. std::uint64_t firstSeq() const { return first_seq_; } private: void trimPendingLocked(); // caller holds ingest_mutex_ const std::size_t max_lines_; const std::size_t max_pending_bytes_; std::deque lines_; // visible model — main thread only std::deque pending_; // guarded by ingest_mutex_ (deque: O(1) pop_front when trimming) std::size_t pending_bytes_ = 0; // sum of pending_ text sizes, guarded by ingest_mutex_ std::size_t dropped_ = 0; // guarded by ingest_mutex_, reset by drain() std::mutex ingest_mutex_; std::uint64_t revision_ = 0; std::uint64_t first_seq_ = 0; // see firstSeq() }; } // namespace ui } // namespace dragonx