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

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