diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e4da12..4b2a2d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -458,6 +458,7 @@ set(APP_SOURCES src/ui/windows/console_command_executor.cpp src/ui/windows/console_command_reference.cpp src/ui/windows/console_input_model.cpp + src/ui/windows/console_model.cpp src/ui/windows/console_output_model.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp @@ -579,8 +580,10 @@ set(APP_HEADERS src/ui/windows/peers_tab.h src/ui/windows/explorer_tab.h src/ui/windows/market_tab.h + src/ui/windows/console_channel.h src/ui/windows/console_command_reference.h src/ui/windows/console_input_model.h + src/ui/windows/console_model.h src/ui/windows/console_output_model.h src/ui/windows/console_tab.h src/ui/windows/console_tab_helpers.h @@ -1018,6 +1021,7 @@ if(BUILD_TESTING) src/ui/windows/balance_address_list.cpp src/ui/windows/balance_recent_tx.cpp src/ui/windows/console_input_model.cpp + src/ui/windows/console_model.cpp src/ui/windows/console_output_model.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp new file mode 100644 index 0000000..52555fd --- /dev/null +++ b/src/ui/windows/console_model.cpp @@ -0,0 +1,45 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "console_model.h" + +#include + +namespace dragonx { +namespace ui { + +void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel) +{ + std::lock_guard lock(ingest_mutex_); + pending_.push_back({text, channel}); +} + +ConsoleModel::DrainResult ConsoleModel::drain() +{ + std::vector incoming; + { + std::lock_guard lock(ingest_mutex_); + if (pending_.empty()) return {}; + incoming.swap(pending_); + } + + DrainResult result; + result.added = incoming.size(); + for (auto& line : incoming) + lines_.push_back(std::move(line)); + + while (lines_.size() > max_lines_) { + lines_.pop_front(); + ++result.popped; + } + return result; +} + +void ConsoleModel::clear() +{ + lines_.clear(); +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h new file mode 100644 index 0000000..8e410a5 --- /dev/null +++ b/src/ui/windows/console_model.h @@ -0,0 +1,77 @@ +// 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 + +namespace dragonx { +namespace ui { + +struct ConsoleModelLine { + std::string text; + ConsoleChannel channel = ConsoleChannel::None; +}; + +class ConsoleModel { +public: + static constexpr std::size_t kDefaultMaxLines = 10000; + + explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines) : max_lines_(maxLines) {} + + // 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(). + void ingest(const std::string& text, ConsoleChannel channel); + + 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 + }; + + // 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(); + + // 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(); } + +private: + const std::size_t max_lines_; + std::deque lines_; // visible model — main thread only + std::vector pending_; // guarded by ingest_mutex_ + std::mutex ingest_mutex_; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index bdace00..509f161 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -208,6 +208,11 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec) while (exec.pollResult(result, isError)) addFormattedResult(result, isError); } + // Drain the ingest queue into the visible model once per frame, after this frame's own + // ingests above and any that arrived from worker threads. From here on lines_ (via + // model_) and the selection/scroll state are touched only on this (main) thread. + drainModel(); + // Main console layout ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); @@ -458,7 +463,6 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) // Copy button — material styled if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { - std::lock_guard lock(lines_mutex_); if (has_selection_) { std::string selected = getSelectedText(); if (!selected.empty()) { @@ -467,7 +471,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) } else { // Copy all output if nothing selected std::string all; - for (const auto& line : lines_) { + for (const auto& line : model_.lines()) { all += line.text + "\n"; } if (!all.empty()) { @@ -493,10 +497,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) } // Line count - { - std::lock_guard lock(lines_mutex_); - ImGui::TextDisabled(TR("console_line_count"), lines_.size()); - } + ImGui::TextDisabled(TR("console_line_count"), model_.size()); ImGui::SameLine(); ImGui::Spacing(); @@ -579,8 +580,9 @@ void ConsoleTab::renderOutput() { using namespace material; auto& S = schema::UI(); - std::lock_guard lock(lines_mutex_); - + // No lock: render() already drained the ingest queue this frame, and the visible model + // (model_) plus all selection/scroll state are touched only on this (main) thread. + // Zero item spacing so Dummy items advance the cursor by exactly their // height. The inter-line gap is added explicitly to layout_.heights // so that layout_.cumulativeY stays perfectly in sync with actual @@ -617,13 +619,13 @@ void ConsoleTab::renderOutput() outputFilter.errorsOnly; visible_indices_.clear(); if (has_filter) { - for (int i = 0; i < static_cast(lines_.size()); i++) { - if (!consoleLinePassesFilter(lines_[i].text, lines_[i].channel, outputFilter)) continue; + for (int i = 0; i < static_cast(model_.size()); i++) { + if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue; visible_indices_.push_back(i); } } else { // No filter - all lines are visible - for (int i = 0; i < static_cast(lines_.size()); i++) { + for (int i = 0; i < static_cast(model_.size()); i++) { visible_indices_.push_back(i); } } @@ -642,7 +644,7 @@ void ConsoleTab::renderOutput() ImFontConsoleMeasure measure(font, fontSize); layout_ = BuildConsoleLayout( visible_count, - [this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; }, + [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, wrap_width, line_height, interLineGap, measure); // Use raw IO for mouse handling to bypass child window event consumption @@ -706,10 +708,10 @@ void ConsoleTab::renderOutput() } if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A)) { // Select all - if (!lines_.empty()) { + if (!model_.empty()) { sel_anchor_ = {0, 0}; - sel_end_ = {static_cast(lines_.size()) - 1, - static_cast(lines_.back().text.size())}; + sel_end_ = {static_cast(model_.size()) - 1, + static_cast(model_.back().text.size())}; has_selection_ = true; } } @@ -761,7 +763,7 @@ void ConsoleTab::renderOutput() last_rendered_vi = vi; int i = visible_indices_[vi]; - const auto& line = lines_[i]; + const auto& line = model_[i]; const auto& segs = layout_.segments[vi]; ImVec2 lineOrigin = ImGui::GetCursorScreenPos(); float totalH = layout_.heights[vi]; @@ -914,7 +916,7 @@ void ConsoleTab::renderOutput() if (has_text_filter) { char filterBuf[128]; snprintf(filterBuf, sizeof(filterBuf), TR("console_showing_lines"), - visible_count, lines_.size()); + visible_count, model_.size()); ImVec2 indicatorPos = ImGui::GetCursorScreenPos(); ImGui::GetWindowDrawList()->AddText(indicatorPos, WithAlpha(Warning(), 180), filterBuf); @@ -925,8 +927,8 @@ void ConsoleTab::renderOutput() if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { TextPos tp = screenToTextPos(ImGui::GetMousePos()); context_token_.clear(); - if (tp.line >= 0 && tp.line < static_cast(lines_.size())) - context_token_ = extractCopyableToken(lines_[tp.line].text, tp.col); + if (tp.line >= 0 && tp.line < static_cast(model_.size())) + context_token_ = extractCopyableToken(model_[tp.line].text, tp.col); } // Right-click context menu @@ -948,17 +950,17 @@ void ConsoleTab::renderOutput() } } if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { - if (!lines_.empty()) { + if (!model_.empty()) { sel_anchor_ = {0, 0}; - sel_end_ = {static_cast(lines_.size()) - 1, - static_cast(lines_.back().text.size())}; + sel_end_ = {static_cast(model_.size()) - 1, + static_cast(model_.back().text.size())}; has_selection_ = true; } } ImGui::Separator(); if (ImGui::MenuItem(TR("console_clear_console"))) { - // Can't call clear() here (already holding lock), just mark for clearing - lines_.clear(); + // View-only clear (main thread) — drop the visible lines and the selection. + model_.clear(); has_selection_ = false; } ImGui::EndPopup(); @@ -1013,7 +1015,7 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); ConsoleHit hit = HitTestConsoleLayout( layout_, static_cast(visible_indices_.size()), - [this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; }, + [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure); TextPos pos; @@ -1045,8 +1047,8 @@ std::string ConsoleTab::getSelectedText() const TextPos start = selectionStart(); TextPos end = selectionEnd(); return ExtractConsoleSelection( - static_cast(lines_.size()), - [this](int i) -> const std::string& { return lines_[i].text; }, + static_cast(model_.size()), + [this](int i) -> const std::string& { return model_[i].text; }, visible_indices_, {start.line, start.col}, {end.line, end.col}); } @@ -1408,37 +1410,39 @@ void ConsoleTab::renderStatusHeader(ConsoleCommandExecutor& exec) void ConsoleTab::addLine(const std::string& line, ConsoleChannel channel) { - std::lock_guard lock(lines_mutex_); - // The channel is the canonical semantic key — text color and the left accent bar are // both derived from it at draw time (channelTextColor / the bar switch). Sources emit // clean text (no "[daemon]"/"[xmrig]"/"[app]"/"[rpc]" prefix); the bar carries origin. - lines_.push_back({line, channel}); + // + // ingest() is thread-safe, so this is callable from the RPC-trace / app-logger worker + // threads. The line becomes visible on the next render() drain (main thread), where the + // line-cap eviction and selection/scroll bookkeeping happen — none of the UI-only state + // is touched here, so there is no cross-thread access to it. + model_.ingest(line, channel); +} - // Limit buffer size — adjust selection indices when lines are removed - // from the front so the highlight stays on the text the user selected. - int popped = 0; - while (lines_.size() > 10000) { - lines_.pop_front(); - popped++; - } - if (popped > 0 && has_selection_) { - sel_anchor_.line -= popped; - sel_end_.line -= popped; +void ConsoleTab::drainModel() +{ + const ConsoleModel::DrainResult dr = model_.drain(); + if (dr.added == 0) return; + + // Lines evicted from the front by the cap shift every index down — move the selection + // with them so the highlight stays on the text the user selected. + if (dr.popped > 0 && has_selection_) { + sel_anchor_.line -= static_cast(dr.popped); + sel_end_.line -= static_cast(dr.popped); if (sel_anchor_.line < 0 && sel_end_.line < 0) { - // Entire selection was in the removed range - has_selection_ = false; + has_selection_ = false; // entire selection was in the removed range is_selecting_ = false; } else { if (sel_anchor_.line < 0) { sel_anchor_.line = 0; sel_anchor_.col = 0; } if (sel_end_.line < 0) { sel_end_.line = 0; sel_end_.col = 0; } } } - - // Track new output while user is scrolled up - if (!auto_scroll_) { - new_lines_since_scroll_++; - } + + // Track new output that arrived while the user is scrolled up (for the indicator). + if (!auto_scroll_) + new_lines_since_scroll_ += static_cast(dr.added); } void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method) @@ -1449,12 +1453,9 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m void ConsoleTab::clear() { - { - std::lock_guard lock(lines_mutex_); - lines_.clear(); - } - // The executor keeps its own log cursors, so a view-clear stays cleared (new output - // still appends). addLine() takes the lock itself, so call it outside the locked scope. + // View-only clear (main thread). The executor keeps its own log cursors, so new output + // still appends. The "cleared" line is ingested and appears on the next frame's drain. + model_.clear(); addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index dc672b2..9827d82 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -5,6 +5,7 @@ #pragma once #include "console_channel.h" +#include "console_model.h" #include "console_text_layout.h" #include "../layout.h" #include "../../daemon/embedded_daemon.h" @@ -85,16 +86,15 @@ public: static ImU32 COLOR_RPC; private: - // Each line stores its semantic ConsoleChannel; the text color and left accent-bar - // color are both derived from it at draw time (channelTextColor / channelBarColor). - struct ConsoleLine { - std::string text; - ConsoleChannel channel = ConsoleChannel::None; - }; - - // Text color for a channel, resolved from the current theme (COLOR_* + material). + // Text color for a channel, resolved from the current theme (COLOR_* + material). Each + // line stores only its semantic ConsoleChannel (in ConsoleModel); the text color and + // the left accent-bar color are both derived from it at draw time. ImU32 channelTextColor(ConsoleChannel channel) const; + // Drain the thread-safe ingest queue into the visible model (once per frame, main + // thread), applying the line cap + selection/scroll bookkeeping for the new lines. + void drainModel(); + // Format a completed command result (JSON role -> channel) into console lines. void addFormattedResult(const std::string& result, bool is_error); void renderStatusHeader(ConsoleCommandExecutor& exec); @@ -117,7 +117,9 @@ private: TextPos selectionStart() const; // Returns the earlier of sel_anchor_ and sel_end_ TextPos selectionEnd() const; // Returns the later of sel_anchor_ and sel_end_ - std::deque lines_; + // Thread-safe line store: producers on any thread call model_.ingest(); the main + // thread drains it once per frame in render(). The visible deque is main-thread-only. + ConsoleModel model_; std::vector command_history_; int history_index_ = -1; char input_buffer_[4096] = {0}; @@ -141,9 +143,7 @@ private: int rowIndex = 0; }; std::vector scanline_rows_; - - std::mutex lines_mutex_; - + // Output filter char filter_text_[128] = {0}; mutable int filter_match_count_ = 0; // lines matching the text filter (for the toolbar) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 050843e..b84cee8 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -13,6 +13,7 @@ #include "ui/windows/balance_address_list.h" #include "ui/windows/balance_recent_tx.h" #include "ui/windows/console_input_model.h" +#include "ui/windows/console_model.h" #include "ui/windows/console_output_model.h" #include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_text_layout.h" @@ -2336,6 +2337,86 @@ void testConsoleTextLayout() EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector{0}, {0, 1}, {1, 3}), std::string("ello")); } +// ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap. +void testConsoleModel() +{ + using dragonx::ui::ConsoleModel; + using dragonx::ui::ConsoleChannel; + + // Basic ingest -> drain: order + channels preserved, counts reported. + { + ConsoleModel m; + m.ingest("one", ConsoleChannel::Command); + m.ingest("two", ConsoleChannel::Info); + m.ingest("three", ConsoleChannel::Error); + auto dr = m.drain(); + EXPECT_EQ(dr.added, static_cast(3)); + EXPECT_EQ(dr.popped, static_cast(0)); + EXPECT_EQ(m.size(), static_cast(3)); + EXPECT_EQ(m[0].text, std::string("one")); + EXPECT_TRUE(m[0].channel == ConsoleChannel::Command); + EXPECT_TRUE(m[2].channel == ConsoleChannel::Error); + EXPECT_EQ(m.back().text, std::string("three")); + + // Draining again with nothing pending is a no-op. + auto empty = m.drain(); + EXPECT_EQ(empty.added, static_cast(0)); + EXPECT_EQ(empty.popped, static_cast(0)); + EXPECT_EQ(m.size(), static_cast(3)); + } + + // Line cap: front lines are evicted, popped is reported for selection adjustment. + { + ConsoleModel m(3); // cap = 3 + for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None); + auto dr = m.drain(); + EXPECT_EQ(dr.added, static_cast(5)); + EXPECT_EQ(dr.popped, static_cast(2)); + EXPECT_EQ(m.size(), static_cast(3)); + EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted + EXPECT_EQ(m[2].text, std::string("4")); + } + + // clear() drops the visible lines but not lines still queued for the next drain. + { + ConsoleModel m; + m.ingest("a", ConsoleChannel::None); + m.drain(); + EXPECT_EQ(m.size(), static_cast(1)); + m.ingest("b", ConsoleChannel::None); // queued, not yet visible + m.clear(); + EXPECT_EQ(m.size(), static_cast(0)); + m.drain(); + EXPECT_EQ(m.size(), static_cast(1)); // "b" survived the clear + EXPECT_EQ(m[0].text, std::string("b")); + } + + // Concurrency: many producer threads ingest while the main thread drains; no line lost. + { + ConsoleModel m(1000000); // large cap so nothing is evicted + const int kThreads = 8, kPerThread = 2000; + std::atomic go{false}; + std::vector producers; + for (int t = 0; t < kThreads; ++t) { + producers.emplace_back([&m, &go, t]() { + while (!go.load()) { /* spin until start */ } + for (int i = 0; i < kPerThread; ++i) + m.ingest("x", ConsoleChannel::None); + }); + } + go.store(true); + // Interleave drains on this thread while producers run. + size_t drained = 0; + while (drained < static_cast(kThreads * kPerThread)) { + drained += m.drain().added; + } + for (auto& th : producers) th.join(); + drained += m.drain().added; // final sweep for anything queued after the last drain + EXPECT_EQ(drained, static_cast(kThreads * kPerThread)); + EXPECT_EQ(m.size(), static_cast(kThreads * kPerThread)); + } +} + void testRendererHelpers() { EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f), @@ -5022,6 +5103,7 @@ int main() testDaemonLifecycleExecution(); testDaemonLifecycleAdapters(); testConsoleTextLayout(); + testConsoleModel(); testRendererHelpers(); testConsoleInputModel(); testMiningBenchmarkModel();