refactor(console): phase 3 — thread-safe ConsoleModel ingest queue; fix data race

Introduce ConsoleModel (console_model.{h,cpp}): an ImGui-free, thread-safe line
store. 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 enforce the line cap, reporting how many were
added and evicted from the front.

This fixes the real data race. Previously a single lines_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 render() touched
auto_scroll_ / new_lines_since_scroll_ outside that lock, racing the background
RPC-trace and app-logger producers that call addLine() on worker threads.

Now:
- addLine() only ingests (lock-free w.r.t. the model); safe from any thread.
- render() drains once per frame, then does the cap/selection/scroll bookkeeping
  on the main thread (new drainModel()).
- The visible deque + all selection/scroll fields are main-thread-only, so every
  lines_mutex_ lock is gone — including the one wrapping the entire renderOutput.

Adds testConsoleModel() covering ingest/drain ordering, the line-cap eviction
counts, view-only clear (queued lines survive), and an 8-thread concurrent
ingest/drain stress with no lost lines. Full-node + lite build clean; ctest
green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:35:25 -05:00
parent 33735b1d52
commit 779c9682d4
6 changed files with 274 additions and 65 deletions

View File

@@ -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

View File

@@ -0,0 +1,45 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "console_model.h"
#include <utility>
namespace dragonx {
namespace ui {
void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel)
{
std::lock_guard<std::mutex> lock(ingest_mutex_);
pending_.push_back({text, channel});
}
ConsoleModel::DrainResult ConsoleModel::drain()
{
std::vector<ConsoleModelLine> incoming;
{
std::lock_guard<std::mutex> 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

View File

@@ -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 <cstddef>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
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<ConsoleModelLine>& 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<ConsoleModelLine> lines_; // visible model — main thread only
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
std::mutex ingest_mutex_;
};
} // namespace ui
} // namespace dragonx

View File

@@ -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<std::mutex> 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<std::mutex> 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<std::mutex> 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<int>(lines_.size()); i++) {
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].channel, outputFilter)) continue;
for (int i = 0; i < static_cast<int>(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<int>(lines_.size()); i++) {
for (int i = 0; i < static_cast<int>(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<int>(lines_.size()) - 1,
static_cast<int>(lines_.back().text.size())};
sel_end_ = {static_cast<int>(model_.size()) - 1,
static_cast<int>(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<int>(lines_.size()))
context_token_ = extractCopyableToken(lines_[tp.line].text, tp.col);
if (tp.line >= 0 && tp.line < static_cast<int>(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<int>(lines_.size()) - 1,
static_cast<int>(lines_.back().text.size())};
sel_end_ = {static_cast<int>(model_.size()) - 1,
static_cast<int>(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<int>(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<int>(lines_.size()),
[this](int i) -> const std::string& { return lines_[i].text; },
static_cast<int>(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<std::mutex> 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<int>(dr.popped);
sel_end_.line -= static_cast<int>(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<int>(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<std::mutex> 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);
}

View File

@@ -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<ConsoleLine> 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<std::string> command_history_;
int history_index_ = -1;
char input_buffer_[4096] = {0};
@@ -141,9 +143,7 @@ private:
int rowIndex = 0;
};
std::vector<ScanlineRow> 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)

View File

@@ -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<int>{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<size_t>(3));
EXPECT_EQ(dr.popped, static_cast<size_t>(0));
EXPECT_EQ(m.size(), static_cast<size_t>(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<size_t>(0));
EXPECT_EQ(empty.popped, static_cast<size_t>(0));
EXPECT_EQ(m.size(), static_cast<size_t>(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<size_t>(5));
EXPECT_EQ(dr.popped, static_cast<size_t>(2));
EXPECT_EQ(m.size(), static_cast<size_t>(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<size_t>(1));
m.ingest("b", ConsoleChannel::None); // queued, not yet visible
m.clear();
EXPECT_EQ(m.size(), static_cast<size_t>(0));
m.drain();
EXPECT_EQ(m.size(), static_cast<size_t>(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<bool> go{false};
std::vector<std::thread> 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<size_t>(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<size_t>(kThreads * kPerThread));
EXPECT_EQ(m.size(), static_cast<size_t>(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();