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

@@ -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();